返回提交历史
Modified
XFEToolBox.Client.Wpf.Test/Program.cs
+21
-0
Modified
XFEToolBox.Server.Test/Program.cs
+37
-1
Added
XFEToolBox.Server/Properties/AssemblyInfo.cs
+3
-0
Modified
XFEToolBox.Server/Services/Chat/ChatServiceBase.cs
+36
-25
Modified
XFEToolBox/Utilities/Chat/ChatApiClient.cs
+7
-1
Modified
XFEToolBox/Views/Pages/ChatPage.xaml
+1
-1
Modified
XFEToolBox/Views/Pages/ChatPage.xaml.cs
+5
-2
XFEstudio/XFEToolBox
提升聊天参数健壮性,优化客户端与测试
- 新增 ChatRequestValueReader,统一处理 JSON 参数的 null 和类型转换 - ChatServiceBase 参数读取重构为调用 ValueReader - 服务端测试覆盖分页参数异常与群聊历史为空场景 - ChatApiClient 增加 20 秒超时及异常抛出 - ChatPage.xaml 会话列表 SelectedItem 改为 OneWay 绑定 - 会话选择事件判重,避免重复打开 - 添加 InternalsVisibleTo,便于测试访问内部成员
940f12f
代码差异
7 个文件
+110
-30
@@ -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
{
@@ -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(
@@ -0,0 +1,3 @@
1
using System.Runtime.CompilerServices;
2
3
[assembly: InternalsVisibleTo("XFEToolBox.Server.Test")]
@@ -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
}
@@ -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;
@@ -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>
@@ -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)