XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEToolBox

提升聊天参数健壮性,优化客户端与测试

- 新增 ChatRequestValueReader,统一处理 JSON 参数的 null 和类型转换 - ChatServiceBase 参数读取重构为调用 ValueReader - 服务端测试覆盖分页参数异常与群聊历史为空场景 - ChatApiClient 增加 20 秒超时及异常抛出 - ChatPage.xaml 会话列表 SelectedItem 改为 OneWay 绑定 - 会话选择事件判重,避免重复打开 - 添加 InternalsVisibleTo,便于测试访问内部成员

940f12f
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

7 个文件 +110 -30
Modified XFEToolBox.Client.Wpf.Test/Program.cs +21 -0
@@ -12,7 +12,9 @@ using System.Windows.Media.Imaging;
12 12 using System.Windows.Shell;
13 13 using System.Windows.Threading;
14 14 using XFEToolBox.Client.Models;
15 using XFEToolBox.Client.Models.Chat;
15 16 using XFEToolBox.Client.Utilities;
17 using XFEToolBox.Client.ViewModel.Chat;
16 18 using XFEToolBox.Client.ViewModel.Pages;
17 19 using XFEToolBox.WpfCore.Controls;
18 20 using XFEToolBox.WpfCore.Windowing;
@@ -21,6 +23,25 @@ namespace XFEToolBox.Client.Wpf.Test;
21 23
22 24 public class Program
23 25 {
26 [Test]
27 public static void ChatLobbySectionSwitchDoesNotBlockUiThread()
28 {
29 var viewModel = new ChatPageViewModel();
30 var stopwatch = Stopwatch.StartNew();
31
32 for (var index = 0; index < 1_000; index++)
33 {
34 viewModel.SelectSectionCommand.Execute(ChatSection.Lobby);
35 Ensure(viewModel.IsLobbySection && viewModel.SelectedSection == ChatSection.Lobby,
36 "大厅选项卡没有切换到大厅状态。");
37 viewModel.SelectSectionCommand.Execute(ChatSection.Conversations);
38 }
39
40 stopwatch.Stop();
41 Ensure(stopwatch.Elapsed < TimeSpan.FromSeconds(1),
42 $"大厅选项卡切换耗时异常:{stopwatch.Elapsed.TotalMilliseconds:N1} ms。");
43 }
44
24 45 [Test]
25 46 public static void PinnedAndRecentConfigurationRecoverFromDuplicatesAndDamage()
26 47 {
Modified XFEToolBox.Server.Test/Program.cs +37 -1
@@ -10,7 +10,9 @@ using XFEToolBox.Server.Core.Options;
10 10 using XFEToolBox.Server.Core.Services;
11 11 using XFEToolBox.Server.Core.Utilities;
12 12 using XFEToolBox.Server.Realtime;
13 using XFEToolBox.Server.Services.Chat;
13 14 using XFEExtension.NetCore.CyberComm;
15 using XFEExtension.NetCore.XFETransform.Json;
14 16
15 17 var tests = new (string Name, Action Run)[]
16 18 {
@@ -24,6 +26,7 @@ var tests = new (string Name, Action Run)[]
24 26 ("系统 CPU 使用率可在负载下被采样", SystemCpuUsageIsMeasuredUnderLoad),
25 27 ("好友私聊具备权限和消息幂等保证", ChatFriendshipAndMessageAreConsistent),
26 28 ("公开与私密群聊及邀请卡片按规则工作", ChatGroupsAndInvitationsFollowVisibilityRules),
29 ("聊天请求允许可空分页参数", ChatRequestAllowsNullablePagingValues),
27 30 ("私密群号具备足够长度并限制账户枚举", ChatGroupNumbersAreStrongAndRateLimited),
28 31 ("聊天附件分块传输校验完整性和访问权限", ChatAttachmentTransferIsAuthorizedAndVerified),
29 32 ("实时票据绑定用途且只能消费一次", ChatRealtimeTicketIsAudienceBoundAndSingleUse),
@@ -297,10 +300,17 @@ static void ChatGroupsAndInvitationsFollowVisibilityRules() => WithChatRepositor
297 300 "alice", "私密讨论组", "private", ChatGroupVisibility.Private).GetAwaiter().GetResult();
298 301 Assert(privateGroup.GroupNumber.Length == ChatRepository.GroupNumberLength &&
299 302 privateGroup.GroupNumber.All(char.IsAsciiDigit), "群号不是 12 位安全随机数字。");
300 var recommended = repository.GetRecommendedGroupsAsync("bob", null).GetAwaiter().GetResult();
303 var recommended = repository.GetRecommendedGroupsAsync("bob", null)
304 .WaitAsync(TimeSpan.FromSeconds(2)).GetAwaiter().GetResult();
301 305 Assert(recommended.Any(group => group.Id == publicGroup.Id), "公开群没有出现在大厅推荐中。");
302 306 Assert(recommended.All(group => group.Id != privateGroup.Id), "私密群泄露到了大厅推荐中。");
303 307
308 var newGroupHistory = repository.GetMessageHistoryAsync(
309 publicGroup.ConversationId, "alice", beforeSequence: null, limit: 50)
310 .WaitAsync(TimeSpan.FromSeconds(2)).GetAwaiter().GetResult();
311 Assert(newGroupHistory.Items.Count == 0 && !newGroupHistory.HasMore && newGroupHistory.NextBeforeSequence is null,
312 "新建群聊的首屏空历史没有正常返回。");
313
304 314 var exactLookup = repository.FindGroupByNumberAsync(privateGroup.GroupNumber, "bob").GetAwaiter().GetResult();
305 315 Assert(exactLookup?.Id == privateGroup.Id, "输入准确群号无法找到私密群。");
306 316
@@ -319,6 +329,32 @@ static void ChatGroupsAndInvitationsFollowVisibilityRules() => WithChatRepositor
319 329 GC.KeepAlive(attachmentRoot);
320 330 });
321 331
332 static void ChatRequestAllowsNullablePagingValues()
333 {
334 var json = XFEJson.Parse("""
335 {
336 "beforeSequence": null,
337 "limit": 50,
338 "offset": null,
339 "enabled": true,
340 "invalidNumber": "not-a-number"
341 }
342 """);
343
344 Assert(ChatRequestValueReader.GetInt64(json["beforeSequence"]) is null,
345 "显式 JSON null 被错误解析为非空 Int64。");
346 Assert(ChatRequestValueReader.GetInt32(json["offset"]) is null,
347 "显式 JSON null 被错误解析为非空 Int32。");
348 Assert(ChatRequestValueReader.GetInt32(json["limit"]) == 50,
349 "有效分页数量没有被正确解析。");
350 Assert(ChatRequestValueReader.GetBoolean(json["enabled"]) == true,
351 "有效布尔参数没有被正确解析。");
352 Assert(ChatRequestValueReader.GetInt64(json["invalidNumber"]) is null,
353 "类型错误的分页参数没有安全地返回空值。");
354 Assert(ChatRequestValueReader.GetInt64(json["missing"]) is null,
355 "缺失的分页参数没有安全地返回空值。");
356 }
357
322 358 static void ChatGroupNumbersAreStrongAndRateLimited() => WithChatRepository((repository, attachmentRoot) =>
323 359 {
324 360 var group = repository.CreateGroupAsync(
Added XFEToolBox.Server/Properties/AssemblyInfo.cs +3 -0
@@ -0,0 +1,3 @@
1 using System.Runtime.CompilerServices;
2
3 [assembly: InternalsVisibleTo("XFEToolBox.Server.Test")]
Modified XFEToolBox.Server/Services/Chat/ChatServiceBase.cs +36 -25
@@ -5,6 +5,7 @@ using XFEToolBox.Server.Core.Chat;
5 5 using XFEToolBox.Server.Realtime;
6 6 using XFEExtension.NetCore.ServerInteractive.Interfaces;
7 7 using XFEExtension.NetCore.ServerInteractive.Utilities.Server.Services.CoreService;
8 using XFEExtension.NetCore.XFETransform.Json;
8 9
9 10 namespace XFEToolBox.Server.Services.Chat;
10 11
@@ -200,35 +201,19 @@ public abstract class ChatServiceBase : ServerCoreUserServiceBase
200 201
201 202 protected string? GetString(string propertyName, bool trim = true, bool allowEmpty = false)
202 203 {
203 try
204 {
205 var value = Json?[propertyName]?.GetValue<string>();
206 if (value is null || (!allowEmpty && string.IsNullOrWhiteSpace(value))) return null;
207 return trim ? value.Trim() : value;
208 }
209 catch (Exception exception) when (exception is InvalidOperationException or FormatException)
210 {
211 return null;
212 }
204 var value = ChatRequestValueReader.GetString(Json?[propertyName]);
205 if (value is null || (!allowEmpty && string.IsNullOrWhiteSpace(value))) return null;
206 return trim ? value.Trim() : value;
213 207 }
214 208
215 protected bool? GetBoolean(string propertyName)
216 {
217 try { return Json?[propertyName]?.GetValue<bool>(); }
218 catch (Exception exception) when (exception is InvalidOperationException or FormatException) { return null; }
219 }
209 protected bool? GetBoolean(string propertyName) =>
210 ChatRequestValueReader.GetBoolean(Json?[propertyName]);
220 211
221 protected int? GetInt32(string propertyName)
222 {
223 try { return Json?[propertyName]?.GetValue<int>(); }
224 catch (Exception exception) when (exception is InvalidOperationException or FormatException) { return null; }
225 }
212 protected int? GetInt32(string propertyName) =>
213 ChatRequestValueReader.GetInt32(Json?[propertyName]);
226 214
227 protected long? GetInt64(string propertyName)
228 {
229 try { return Json?[propertyName]?.GetValue<long>(); }
230 catch (Exception exception) when (exception is InvalidOperationException or FormatException) { return null; }
231 }
215 protected long? GetInt64(string propertyName) =>
216 ChatRequestValueReader.GetInt64(Json?[propertyName]);
232 217
233 218 protected TEnum? GetEnum<TEnum>(string propertyName) where TEnum : struct, Enum
234 219 {
@@ -331,3 +316,29 @@ public abstract class ChatServiceBase : ServerCoreUserServiceBase
331 316 _ => HttpStatusCode.InternalServerError
332 317 };
333 318 }
319
320 internal static class ChatRequestValueReader
321 {
322 public static string? GetString(XFEJsonNode? node) =>
323 TryGetValue(node, out string? value) ? value : null;
324
325 public static bool? GetBoolean(XFEJsonNode? node) =>
326 TryGetValue(node, out bool value) ? value : null;
327
328 public static int? GetInt32(XFEJsonNode? node) =>
329 TryGetValue(node, out int value) ? value : null;
330
331 public static long? GetInt64(XFEJsonNode? node) =>
332 TryGetValue(node, out long value) ? value : null;
333
334 private static bool TryGetValue<T>(XFEJsonNode? node, out T? value)
335 {
336 if (node is null || node.IsNull)
337 {
338 value = default;
339 return false;
340 }
341
342 return node.TryGetValue(out value);
343 }
344 }
Modified XFEToolBox/Utilities/Chat/ChatApiClient.cs +7 -1
@@ -16,6 +16,7 @@ public sealed class ChatApiException(string message, HttpStatusCode? statusCode
16 16 public sealed class ChatApiClient
17 17 {
18 18 public const int TransferChunkSize = 192 * 1024;
19 private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(20);
19 20
20 21 public Task<ChatUserSummary[]> SearchUsersAsync(string query, int limit = 30) =>
21 22 RequestAsync<ChatUserSummary[]>("chatUsersSearch", query.Trim(), Math.Clamp(limit, 1, 100));
@@ -222,7 +223,8 @@ public sealed class ChatApiClient
222 223 try
223 224 {
224 225 var requestParameters = parameters.Select(static value => value!).ToArray();
225 var response = await ClientSession.Requester.Request<T>(name, requestParameters);
226 using var timeout = new CancellationTokenSource(RequestTimeout);
227 var response = await ClientSession.Requester.RequestAsync<T>(name, timeout.Token, requestParameters);
226 228 if ((int)response.StatusCode is < 200 or >= 300 || response.Result is null)
227 229 {
228 230 var message = string.IsNullOrWhiteSpace(response.Message) ? "聊天服务器请求失败。" : response.Message;
@@ -230,6 +232,10 @@ public sealed class ChatApiClient
230 232 }
231 233 return response.Result;
232 234 }
235 catch (OperationCanceledException exception)
236 {
237 throw new ChatApiException("聊天服务器响应超时,请稍后重试。", null, exception);
238 }
233 239 catch (ChatApiException)
234 240 {
235 241 throw;
Modified XFEToolBox/Views/Pages/ChatPage.xaml +1 -1
@@ -320,7 +320,7 @@
320 320 <StackPanel><TextBlock Text="最近会话" Style="{StaticResource HeadingStyle}"/><TextBlock Text="好友与群聊消息" Style="{StaticResource HintStyle}" Margin="0,3,0,0"/></StackPanel>
321 321 <Button Content="↻" Style="{StaticResource IconButtonStyle}" HorizontalAlignment="Right" Command="{Binding RefreshAllCommand}" ToolTip="刷新会话"/>
322 322 </Grid>
323 <ListBox Grid.Row="1" ItemsSource="{Binding Conversations}" SelectedItem="{Binding SelectedConversation, Mode=TwoWay}"
323 <ListBox Grid.Row="1" ItemsSource="{Binding Conversations}" SelectedItem="{Binding SelectedConversation, Mode=OneWay}"
324 324 ItemTemplate="{StaticResource ConversationTemplate}" ItemContainerStyle="{StaticResource ChatListItemStyle}"
325 325 SelectionChanged="ConversationList_SelectionChanged"/>
326 326 </Grid>
Modified XFEToolBox/Views/Pages/ChatPage.xaml.cs +5 -2
@@ -110,8 +110,11 @@ public partial class ChatPage : Page
110 110
111 111 private async void ConversationList_SelectionChanged(object sender, SelectionChangedEventArgs e)
112 112 {
113 if (sender is ListBox { SelectedItem: ChatConversationItem item })
114 await ViewModel.OpenConversationCommand.ExecuteAsync(item);
113 if (sender is not ListBox { SelectedItem: ChatConversationItem item } ||
114 ReferenceEquals(item, ViewModel.SelectedConversation))
115 return;
116
117 await ViewModel.OpenConversationCommand.ExecuteAsync(item);
115 118 }
116 119
117 120 private async void GroupSearchBox_KeyDown(object sender, KeyEventArgs e)