diff --git a/src/BuildFlow.Host/Endpoints/Rag/Chat/EditMessageStreamEndpoint.cs b/src/BuildFlow.Host/Endpoints/Rag/Chat/EditMessageStreamEndpoint.cs
index 72fa5cc..d288220 100644
--- a/src/BuildFlow.Host/Endpoints/Rag/Chat/EditMessageStreamEndpoint.cs
+++ b/src/BuildFlow.Host/Endpoints/Rag/Chat/EditMessageStreamEndpoint.cs
@@ -1,30 +1,13 @@
-using System.Text;
using System.Text.Json;
-using BuildFlow.Domain.Entities.ChatMessage;
-using BuildFlow.Domain.Enums;
-using BuildFlow.Domain.Interfaces;
-using BuildFlow.Infrastructure.Persistence;
-using BuildFlow.Shared.Domain.Errors;
-using AiChatMessage = Microsoft.Extensions.AI.ChatMessage;
-using AiChatRole = Microsoft.Extensions.AI.ChatRole;
+using BuildFlow.Application.Chat.Commands;
+using MediatR;
namespace BuildFlow.Host.Endpoints.Rag.Chat;
///
-/// 编辑已发送的用户消息并重新流式生成 AI 回复。
-/// 路由:POST /chat/conversations/{ConversationId}/messages/{MessageId}/edit-stream
-///
-/// 处理流程(与 StreamMessageEndpoint 保持一致的 endpoint 风格,逻辑在端点内完成):
-/// 1. 校验会话存在 + 目标消息存在且 Role == User(只允许编辑自己的提问)。
-/// 2. 更新该 User 消息的 Content 与审计字段(DateTime.UtcNow)。
-/// 3. 物理删除该消息"之后"的所有消息(含它的旧 AI 回复及后续所有轮次)——
-/// ChatMessageEntity 未实现 ISoftDelete,直接 Remove 符合现状。
-/// 4. 清理该会话的全部消息缓存,按 DB 现状重建缓存。
-/// 5. 基于更新后的完整历史重新流式生成 AI 回复,流式结束后持久化 Assistant 消息。
-///
-/// SSE 协议与 StreamMessageEndpoint 一致:data:{content} / event:done。
+/// 将聊天编辑流式请求转换为应用层命令并编码为 SSE 响应。
///
-public class EditMessageStreamEndpoint(RagDbContext db, IAIChatAgent chatAgent, IChatMessageCache cache)
+public class EditMessageStreamEndpoint(IMediator mediator)
: Endpoint
{
public override void Configure()
@@ -37,88 +20,29 @@ public class EditMessageStreamEndpoint(RagDbContext db, IAIChatAgent chatAgent,
{
var conversationId = Route("ConversationId");
var messageId = Route("MessageId");
-
- var conversation = await db.Conversations.FindAsync([conversationId], ct);
- if (conversation is null)
- {
- throw new NotFoundException(ErrorCode.NotFound, "会话不存在");
- }
-
- var targetMessage = await db.ChatMessages.FindAsync([messageId], ct);
- // 只允许编辑自己的提问(Role == User)。Assistant 消息不允许编辑。
- if (targetMessage is null || targetMessage.ConversationId != conversationId)
- {
- throw new NotFoundException(ErrorCode.NotFound, "消息不存在");
- }
- if (targetMessage.Role != ChatRoleEnum.User)
- {
- throw new BusinessException(ErrorCode.Business, "仅支持编辑用户提问消息");
- }
-
- var editedAt = DateTime.UtcNow;
-
- // 1. 更新目标消息内容与审计字段
- targetMessage.Content = req.Content;
- targetMessage.UpdatedAt = editedAt;
- targetMessage.UpdatedBy = "system"; // 编辑流无独立操作者,统一以 system 标记;端点已要求 chat:send 权限
-
- // 2. 物理删除该消息"之后"的所有消息(CreatedAt 严格大于该消息)
- // 用 editedAt 备份原 CreatedAt 之前的值不必要:删除判定基于消息原始 CreatedAt。
- var messagesToRemove = await db.ChatMessages
- .Where(m => m.ConversationId == conversationId && m.CreatedAt > targetMessage.CreatedAt)
- .ToListAsync(ct);
- db.ChatMessages.RemoveRange(messagesToRemove);
-
- await db.SaveChangesAsync(ct);
-
- // 3. 重建该会话的消息缓存:删除旧缓存,按 DB 现状全量回填
- await cache.RemoveAsync(conversationId, ct);
- var remainingMessages = await db.ChatMessages
- .Where(m => m.ConversationId == conversationId)
- .OrderBy(m => m.CreatedAt)
- .ToListAsync(ct);
- await cache.SetMessagesAsync(
- conversationId,
- remainingMessages.Select(m => new CachedChatMessage(
- m.Id, m.Role.ToString(), m.Content, m.TokenUsage, m.CreatedAt)).ToList(),
- ct);
-
- // 4. 构造 LLM 上下文(更新后的完整历史)
- var chatMessages = remainingMessages.Select(m => new AiChatMessage(
- m.Role == ChatRoleEnum.User ? AiChatRole.User : AiChatRole.Assistant,
- m.Content)).ToList();
-
- // 5. 流式生成 + SSE 推送
HttpContext.Response.ContentType = "text/event-stream";
HttpContext.Response.Headers.CacheControl = "no-cache";
HttpContext.Response.Headers.Connection = "keep-alive";
- var fullReply = new StringBuilder();
-
- await foreach (var chunk in chatAgent.RunStreamingAsync(chatMessages, ct))
+ await foreach (var streamEvent in mediator.CreateStream(
+ new EditMessageStreamCommand(
+ conversationId,
+ messageId,
+ req.Content),
+ ct))
{
- fullReply.Append(chunk);
- var sseData = JsonSerializer.Serialize(new { content = chunk });
- await HttpContext.Response.WriteAsync($"data: {sseData}\n\n", ct);
+ if (streamEvent.MessageId is Guid completedMessageId)
+ {
+ var doneData = JsonSerializer.Serialize(new { messageId = completedMessageId });
+ await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct);
+ }
+ else
+ {
+ var data = JsonSerializer.Serialize(new { content = streamEvent.Content });
+ await HttpContext.Response.WriteAsync($"data: {data}\n\n", ct);
+ }
+
await HttpContext.Response.Body.FlushAsync(ct);
}
-
- var replyText = fullReply.ToString();
-
- var assistantMessage = new ChatMessageEntity
- {
- ConversationId = conversationId,
- Role = ChatRoleEnum.Assistant,
- Content = replyText
- };
- await db.ChatMessages.AddAsync(assistantMessage, ct);
- await db.SaveChangesAsync(ct);
- await cache.AppendMessageAsync(conversationId,
- new CachedChatMessage(assistantMessage.Id, ChatRoleEnum.Assistant.ToString(),
- assistantMessage.Content, null, assistantMessage.CreatedAt), ct);
-
- var doneData = JsonSerializer.Serialize(new { messageId = assistantMessage.Id });
- await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct);
- await HttpContext.Response.Body.FlushAsync(ct);
}
}
diff --git a/src/BuildFlow.Host/Endpoints/Rag/Chat/StreamMessageEndpoint.cs b/src/BuildFlow.Host/Endpoints/Rag/Chat/StreamMessageEndpoint.cs
index 061f901..8a805f8 100644
--- a/src/BuildFlow.Host/Endpoints/Rag/Chat/StreamMessageEndpoint.cs
+++ b/src/BuildFlow.Host/Endpoints/Rag/Chat/StreamMessageEndpoint.cs
@@ -1,15 +1,13 @@
-using System.Text;
using System.Text.Json;
-using BuildFlow.Domain.Entities.ChatMessage;
-using BuildFlow.Domain.Enums;
-using BuildFlow.Domain.Interfaces;
-using BuildFlow.Infrastructure.Persistence;
-using AiChatMessage = Microsoft.Extensions.AI.ChatMessage;
-using AiChatRole = Microsoft.Extensions.AI.ChatRole;
+using BuildFlow.Application.Chat.Commands;
+using MediatR;
namespace BuildFlow.Host.Endpoints.Rag.Chat;
-public class StreamMessageEndpoint(RagDbContext db, IAIChatAgent chatAgent, IChatMessageCache cache)
+///
+/// 将聊天流式请求转换为应用层命令并编码为 SSE 响应。
+///
+public class StreamMessageEndpoint(IMediator mediator)
: Endpoint
{
public override void Configure()
@@ -21,63 +19,26 @@ public class StreamMessageEndpoint(RagDbContext db, IAIChatAgent chatAgent, ICha
public override async Task HandleAsync(StreamMessageRequest req, CancellationToken ct)
{
var conversationId = Route("ConversationId");
-
- var conversation = await db.Conversations.FindAsync([conversationId], ct);
- if (conversation is null)
- {
- HttpContext.Response.StatusCode = 404;
- return;
- }
-
- var userMessage = new ChatMessageEntity
- {
- ConversationId = conversationId,
- Role = ChatRoleEnum.User,
- Content = req.Content
- };
- await db.ChatMessages.AddAsync(userMessage, ct);
- await db.SaveChangesAsync(ct);
- await cache.AppendMessageAsync(conversationId,
- new CachedChatMessage(userMessage.Id, ChatRoleEnum.User.ToString(), userMessage.Content, null, userMessage.CreatedAt), ct);
-
- var dbMessages = await db.ChatMessages
- .Where(m => m.ConversationId == conversationId)
- .OrderBy(m => m.CreatedAt)
- .ToListAsync(ct);
-
- var chatMessages = dbMessages.Select(m => new AiChatMessage(
- m.Role == ChatRoleEnum.User ? AiChatRole.User : AiChatRole.Assistant,
- m.Content)).ToList();
-
HttpContext.Response.ContentType = "text/event-stream";
HttpContext.Response.Headers.CacheControl = "no-cache";
HttpContext.Response.Headers.Connection = "keep-alive";
- var fullReply = new StringBuilder();
-
- await foreach (var chunk in chatAgent.RunStreamingAsync(chatMessages, ct))
+ await foreach (var streamEvent in mediator.CreateStream(
+ new StreamMessageCommand(conversationId, req.Content),
+ ct))
{
- fullReply.Append(chunk);
- var sseData = JsonSerializer.Serialize(new { content = chunk });
- await HttpContext.Response.WriteAsync($"data: {sseData}\n\n", ct);
+ if (streamEvent.MessageId is Guid messageId)
+ {
+ var doneData = JsonSerializer.Serialize(new { messageId });
+ await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct);
+ }
+ else
+ {
+ var data = JsonSerializer.Serialize(new { content = streamEvent.Content });
+ await HttpContext.Response.WriteAsync($"data: {data}\n\n", ct);
+ }
+
await HttpContext.Response.Body.FlushAsync(ct);
}
-
- var replyText = fullReply.ToString();
-
- var assistantMessage = new ChatMessageEntity
- {
- ConversationId = conversationId,
- Role = ChatRoleEnum.Assistant,
- Content = replyText
- };
- await db.ChatMessages.AddAsync(assistantMessage, ct);
- await db.SaveChangesAsync(ct);
- await cache.AppendMessageAsync(conversationId,
- new CachedChatMessage(assistantMessage.Id, ChatRoleEnum.Assistant.ToString(), assistantMessage.Content, null, assistantMessage.CreatedAt), ct);
-
- var doneData = JsonSerializer.Serialize(new { messageId = assistantMessage.Id });
- await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct);
- await HttpContext.Response.Body.FlushAsync(ct);
}
}
diff --git a/src/modules/rag/Application/Chat/Commands/EditMessageStreamCommand.cs b/src/modules/rag/Application/Chat/Commands/EditMessageStreamCommand.cs
new file mode 100644
index 0000000..f6cbe06
--- /dev/null
+++ b/src/modules/rag/Application/Chat/Commands/EditMessageStreamCommand.cs
@@ -0,0 +1,12 @@
+using BuildFlow.Application.Chat.DTOs;
+using MediatR;
+
+namespace BuildFlow.Application.Chat.Commands;
+
+///
+/// 编辑当前用户会话中的提问并重新流式生成回复。
+///
+public sealed record EditMessageStreamCommand(
+ Guid ConversationId,
+ Guid MessageId,
+ string Content) : IStreamRequest;
diff --git a/src/modules/rag/Application/Chat/Commands/StreamMessageCommand.cs b/src/modules/rag/Application/Chat/Commands/StreamMessageCommand.cs
new file mode 100644
index 0000000..6337e74
--- /dev/null
+++ b/src/modules/rag/Application/Chat/Commands/StreamMessageCommand.cs
@@ -0,0 +1,10 @@
+using BuildFlow.Application.Chat.DTOs;
+using MediatR;
+
+namespace BuildFlow.Application.Chat.Commands;
+
+///
+/// 向当前用户拥有的会话发送流式消息。
+///
+public sealed record StreamMessageCommand(Guid ConversationId, string Content)
+ : IStreamRequest;
diff --git a/src/modules/rag/Application/Chat/Dtos/ChatStreamEvent.cs b/src/modules/rag/Application/Chat/Dtos/ChatStreamEvent.cs
new file mode 100644
index 0000000..c1f4fa1
--- /dev/null
+++ b/src/modules/rag/Application/Chat/Dtos/ChatStreamEvent.cs
@@ -0,0 +1,11 @@
+namespace BuildFlow.Application.Chat.DTOs;
+
+///
+/// 聊天流的内容片段或完成事件。
+///
+public sealed record ChatStreamEvent(string? Content, Guid? MessageId)
+{
+ public static ChatStreamEvent Chunk(string content) => new(content, null);
+
+ public static ChatStreamEvent Done(Guid messageId) => new(null, messageId);
+}
diff --git a/src/modules/rag/Application/Chat/Handlers/EditMessageStreamCommandHandler.cs b/src/modules/rag/Application/Chat/Handlers/EditMessageStreamCommandHandler.cs
new file mode 100644
index 0000000..cbb5632
--- /dev/null
+++ b/src/modules/rag/Application/Chat/Handlers/EditMessageStreamCommandHandler.cs
@@ -0,0 +1,101 @@
+using System.Runtime.CompilerServices;
+using System.Text;
+using BuildFlow.Application.Chat.Commands;
+using BuildFlow.Application.Chat.DTOs;
+using BuildFlow.Domain.Entities.ChatMessage;
+using BuildFlow.Domain.Enums;
+using BuildFlow.Domain.Interfaces;
+using BuildFlow.Infrastructure.Persistence;
+using BuildFlow.Shared.Domain.Common;
+using BuildFlow.Shared.Domain.Exceptions;
+using MediatR;
+using AiChatMessage = Microsoft.Extensions.AI.ChatMessage;
+using AiChatRole = Microsoft.Extensions.AI.ChatRole;
+
+namespace BuildFlow.Application.Chat.Handlers;
+
+///
+/// 编辑当前用户的提问,截断后续消息并重新流式生成回复。
+///
+public sealed class EditMessageStreamCommandHandler(
+ RagDbContext db,
+ IAIChatAgent chatAgent,
+ IChatMessageCache cache,
+ ICurrentUserContext userContext)
+ : IStreamRequestHandler
+{
+ public async IAsyncEnumerable Handle(
+ EditMessageStreamCommand request,
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ var userIdText = userContext.GetRequiredUserId();
+ var userId = Guid.Parse(userIdText);
+ var conversation = await db.Conversations.FirstOrDefaultAsync(
+ x => x.Id == request.ConversationId && x.UserId == userId,
+ ct)
+ ?? throw new NotFoundException(ErrorCode.NotFound, "会话不存在");
+
+ var targetMessage = await db.ChatMessages.FirstOrDefaultAsync(
+ x => x.Id == request.MessageId && x.ConversationId == conversation.Id,
+ ct)
+ ?? throw new NotFoundException(ErrorCode.NotFound, "消息不存在");
+ if (targetMessage.Role != ChatRoleEnum.User)
+ throw new BusinessException(ErrorCode.Business, "仅支持编辑用户提问消息");
+
+ var targetCreatedAt = targetMessage.CreatedAt;
+ targetMessage.Content = request.Content;
+ targetMessage.UpdatedAt = DateTime.UtcNow;
+ targetMessage.UpdatedBy = userIdText;
+
+ var messagesToRemove = await db.ChatMessages
+ .Where(x => x.ConversationId == conversation.Id && x.CreatedAt > targetCreatedAt)
+ .ToListAsync(ct);
+ db.ChatMessages.RemoveRange(messagesToRemove);
+ await db.SaveChangesAsync(ct);
+
+ await cache.RemoveAsync(conversation.Id, ct);
+ var remainingMessages = await db.ChatMessages
+ .Where(x => x.ConversationId == conversation.Id)
+ .OrderBy(x => x.CreatedAt)
+ .ToListAsync(ct);
+ await cache.SetMessagesAsync(
+ conversation.Id,
+ remainingMessages.Select(x => new CachedChatMessage(
+ x.Id,
+ x.Role.ToString(),
+ x.Content,
+ x.TokenUsage,
+ x.CreatedAt)).ToList(),
+ ct);
+
+ var chatMessages = remainingMessages.Select(x => new AiChatMessage(
+ x.Role == ChatRoleEnum.User ? AiChatRole.User : AiChatRole.Assistant,
+ x.Content)).ToList();
+ var fullReply = new StringBuilder();
+ await foreach (var chunk in chatAgent.RunStreamingAsync(chatMessages, ct))
+ {
+ fullReply.Append(chunk);
+ yield return ChatStreamEvent.Chunk(chunk);
+ }
+
+ var assistantMessage = new ChatMessageEntity
+ {
+ ConversationId = conversation.Id,
+ Role = ChatRoleEnum.Assistant,
+ Content = fullReply.ToString(),
+ };
+ await db.ChatMessages.AddAsync(assistantMessage, ct);
+ await db.SaveChangesAsync(ct);
+ await cache.AppendMessageAsync(
+ conversation.Id,
+ new CachedChatMessage(
+ assistantMessage.Id,
+ ChatRoleEnum.Assistant.ToString(),
+ assistantMessage.Content,
+ null,
+ assistantMessage.CreatedAt),
+ ct);
+
+ yield return ChatStreamEvent.Done(assistantMessage.Id);
+ }
+}
diff --git a/src/modules/rag/Application/Chat/Handlers/StreamMessageCommandHandler.cs b/src/modules/rag/Application/Chat/Handlers/StreamMessageCommandHandler.cs
new file mode 100644
index 0000000..f8d8cb8
--- /dev/null
+++ b/src/modules/rag/Application/Chat/Handlers/StreamMessageCommandHandler.cs
@@ -0,0 +1,90 @@
+using System.Runtime.CompilerServices;
+using System.Text;
+using BuildFlow.Application.Chat.Commands;
+using BuildFlow.Application.Chat.DTOs;
+using BuildFlow.Domain.Entities.ChatMessage;
+using BuildFlow.Domain.Enums;
+using BuildFlow.Domain.Interfaces;
+using BuildFlow.Infrastructure.Persistence;
+using BuildFlow.Shared.Domain.Common;
+using BuildFlow.Shared.Domain.Exceptions;
+using MediatR;
+using AiChatMessage = Microsoft.Extensions.AI.ChatMessage;
+using AiChatRole = Microsoft.Extensions.AI.ChatRole;
+
+namespace BuildFlow.Application.Chat.Handlers;
+
+///
+/// 向当前用户会话发送消息并产出流式回复事件。
+///
+public sealed class StreamMessageCommandHandler(
+ RagDbContext db,
+ IAIChatAgent chatAgent,
+ IChatMessageCache cache,
+ ICurrentUserContext userContext)
+ : IStreamRequestHandler
+{
+ public async IAsyncEnumerable Handle(
+ StreamMessageCommand request,
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ var userId = Guid.Parse(userContext.GetRequiredUserId());
+ var conversation = await db.Conversations.FirstOrDefaultAsync(
+ x => x.Id == request.ConversationId && x.UserId == userId,
+ ct)
+ ?? throw new NotFoundException(ErrorCode.NotFound, "会话不存在");
+
+ var userMessage = new ChatMessageEntity
+ {
+ ConversationId = conversation.Id,
+ Role = ChatRoleEnum.User,
+ Content = request.Content,
+ };
+ await db.ChatMessages.AddAsync(userMessage, ct);
+ await db.SaveChangesAsync(ct);
+ await cache.AppendMessageAsync(
+ conversation.Id,
+ new CachedChatMessage(
+ userMessage.Id,
+ ChatRoleEnum.User.ToString(),
+ userMessage.Content,
+ null,
+ userMessage.CreatedAt),
+ ct);
+
+ var history = await db.ChatMessages
+ .Where(x => x.ConversationId == conversation.Id)
+ .OrderBy(x => x.CreatedAt)
+ .ToListAsync(ct);
+ var chatMessages = history.Select(x => new AiChatMessage(
+ x.Role == ChatRoleEnum.User ? AiChatRole.User : AiChatRole.Assistant,
+ x.Content)).ToList();
+
+ var fullReply = new StringBuilder();
+ await foreach (var chunk in chatAgent.RunStreamingAsync(chatMessages, ct))
+ {
+ fullReply.Append(chunk);
+ yield return ChatStreamEvent.Chunk(chunk);
+ }
+
+ var assistantMessage = new ChatMessageEntity
+ {
+ ConversationId = conversation.Id,
+ Role = ChatRoleEnum.Assistant,
+ Content = fullReply.ToString(),
+ };
+ await db.ChatMessages.AddAsync(assistantMessage, ct);
+ await db.SaveChangesAsync(ct);
+ await cache.AppendMessageAsync(
+ conversation.Id,
+ new CachedChatMessage(
+ assistantMessage.Id,
+ ChatRoleEnum.Assistant.ToString(),
+ assistantMessage.Content,
+ null,
+ assistantMessage.CreatedAt),
+ ct);
+
+ yield return ChatStreamEvent.Done(assistantMessage.Id);
+ }
+}
diff --git a/tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs b/tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs
index 235aeae..2f7761e 100644
--- a/tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs
+++ b/tests/BuildFlow.Architecture.Tests/RagSecurityArchitectureTests.cs
@@ -17,6 +17,21 @@ public class RagSecurityArchitectureTests
Assert.DoesNotContain("AllowAnonymous()", source);
}
+ [Theory]
+ [InlineData("src/BuildFlow.Host/Endpoints/Rag/Chat/StreamMessageEndpoint.cs")]
+ [InlineData("src/BuildFlow.Host/Endpoints/Rag/Chat/EditMessageStreamEndpoint.cs")]
+ public void ChatStreamEndpointsShouldUseMediatorWithoutDbContext(string relativePath)
+ {
+ var source = ReadSource(relativePath);
+
+ Assert.Contains("IMediator mediator", source);
+ Assert.Contains("mediator.CreateStream", source);
+ Assert.Contains("event: done", source);
+ Assert.DoesNotContain("RagDbContext", source);
+ Assert.DoesNotContain("IAIChatAgent", source);
+ Assert.DoesNotContain("IChatMessageCache", source);
+ }
+
private static string ReadSource(string relativePath)
{
return File.ReadAllText(Path.Combine(
diff --git a/tests/BuildFlow.Rag.Tests/ChatStreamOwnershipTests.cs b/tests/BuildFlow.Rag.Tests/ChatStreamOwnershipTests.cs
new file mode 100644
index 0000000..39829e9
--- /dev/null
+++ b/tests/BuildFlow.Rag.Tests/ChatStreamOwnershipTests.cs
@@ -0,0 +1,183 @@
+using BuildFlow.Application.Chat.Commands;
+using BuildFlow.Application.Chat.DTOs;
+using BuildFlow.Application.Chat.Handlers;
+using BuildFlow.Domain.Entities.ChatMessage;
+using BuildFlow.Domain.Entities.Conversation;
+using BuildFlow.Domain.Enums;
+using BuildFlow.Infrastructure.Persistence;
+using BuildFlow.Rag.Tests.Stub;
+using BuildFlow.Shared.Domain.Errors;
+using BuildFlow.Shared.Domain.Exceptions;
+using BuildFlow.Shared.Infrastructure;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace BuildFlow.Rag.Tests;
+
+///
+/// RAG 流式发送和编辑重生成的对象所有权与事件契约测试。
+///
+public class ChatStreamOwnershipTests
+{
+ [Fact]
+ public async Task StreamMessageForOwnerShouldYieldChunksThenDoneAndPersistMessages()
+ {
+ var userContext = new TestCurrentUserContext();
+ await using var db = CreateDb(userContext);
+ var conversationId = await SeedConversationAsync(
+ db,
+ Guid.Parse(userContext.UserId));
+ var cache = new StubChatMessageCache();
+ var handler = new StreamMessageCommandHandler(
+ db,
+ new StubAIChatAgent { StreamTokens = ["你", "好", "!"] },
+ cache,
+ userContext);
+
+ var events = await CollectAsync(handler.Handle(
+ new StreamMessageCommand(conversationId, "你好"),
+ CancellationToken.None));
+
+ events.Take(3).Select(x => x.Content).Should().Equal("你", "好", "!");
+ events.Should().HaveCount(4);
+ events[^1].MessageId.Should().NotBeNull();
+ events[^1].MessageId!.Value.Should().NotBeEmpty();
+ db.ChatMessages.Count(x => x.Role == ChatRoleEnum.User).Should().Be(1);
+ db.ChatMessages.Count(x => x.Role == ChatRoleEnum.Assistant).Should().Be(1);
+ cache.Count(conversationId).Should().Be(2);
+ }
+
+ [Fact]
+ public async Task StreamMessageForNonOwnerShouldThrowNotFoundBeforeWriting()
+ {
+ var userContext = new TestCurrentUserContext();
+ await using var db = CreateDb(userContext);
+ var conversationId = await SeedConversationAsync(db, Guid.NewGuid());
+ var cache = new StubChatMessageCache();
+ var handler = new StreamMessageCommandHandler(
+ db,
+ new StubAIChatAgent(),
+ cache,
+ userContext);
+
+ var act = () => CollectAsync(handler.Handle(
+ new StreamMessageCommand(conversationId, "越权消息"),
+ CancellationToken.None));
+
+ await act.Should().ThrowAsync()
+ .Where(x => x.Code == ErrorCode.NotFound && x.Message == "会话不存在");
+ db.ChatMessages.Should().BeEmpty();
+ cache.Count(conversationId).Should().Be(0);
+ }
+
+ [Fact]
+ public async Task EditStreamForNonOwnerShouldThrowNotFoundBeforeEditing()
+ {
+ var userContext = new TestCurrentUserContext();
+ await using var db = CreateDb(userContext);
+ var conversationId = await SeedConversationAsync(db, Guid.NewGuid());
+ var target = new ChatMessageEntity
+ {
+ ConversationId = conversationId,
+ Role = ChatRoleEnum.User,
+ Content = "原始问题",
+ };
+ var later = new ChatMessageEntity
+ {
+ ConversationId = conversationId,
+ Role = ChatRoleEnum.Assistant,
+ Content = "原始回答",
+ };
+ db.ChatMessages.AddRange(target, later);
+ await db.SaveChangesAsync();
+ var handler = new EditMessageStreamCommandHandler(
+ db,
+ new StubAIChatAgent(),
+ new StubChatMessageCache(),
+ userContext);
+
+ var act = () => CollectAsync(handler.Handle(
+ new EditMessageStreamCommand(conversationId, target.Id, "越权编辑"),
+ CancellationToken.None));
+
+ await act.Should().ThrowAsync()
+ .Where(x => x.Code == ErrorCode.NotFound && x.Message == "会话不存在");
+ target.Content.Should().Be("原始问题");
+ db.ChatMessages.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public async Task EditStreamForOwnerShouldPreserveChunkAndDoneEventContract()
+ {
+ var userContext = new TestCurrentUserContext();
+ await using var db = CreateDb(userContext);
+ var conversationId = await SeedConversationAsync(
+ db,
+ Guid.Parse(userContext.UserId));
+ var target = new ChatMessageEntity
+ {
+ ConversationId = conversationId,
+ Role = ChatRoleEnum.User,
+ Content = "原始问题",
+ };
+ db.ChatMessages.Add(target);
+ await db.SaveChangesAsync();
+ await Task.Delay(5);
+ db.ChatMessages.Add(new ChatMessageEntity
+ {
+ ConversationId = conversationId,
+ Role = ChatRoleEnum.Assistant,
+ Content = "旧回答",
+ });
+ await db.SaveChangesAsync();
+ var cache = new StubChatMessageCache();
+ var handler = new EditMessageStreamCommandHandler(
+ db,
+ new StubAIChatAgent { StreamTokens = ["新", "回", "答"] },
+ cache,
+ userContext);
+
+ var events = await CollectAsync(handler.Handle(
+ new EditMessageStreamCommand(conversationId, target.Id, "新问题"),
+ CancellationToken.None));
+
+ events.Take(3).Select(x => x.Content).Should().Equal("新", "回", "答");
+ events.Should().HaveCount(4);
+ events[^1].MessageId.Should().NotBeNull();
+ events[^1].MessageId!.Value.Should().NotBeEmpty();
+ target.Content.Should().Be("新问题");
+ db.ChatMessages.Should().HaveCount(2);
+ db.ChatMessages.Single(x => x.Role == ChatRoleEnum.Assistant)
+ .Content.Should().Be("新回答");
+ cache.Count(conversationId).Should().Be(2);
+ }
+
+ private static RagDbContext CreateDb(TestCurrentUserContext userContext)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase($"chat-stream-{Guid.NewGuid():N}")
+ .AddInterceptors(new AuditInterceptor(userContext))
+ .Options;
+ return new RagDbContext(options);
+ }
+
+ private static async Task SeedConversationAsync(RagDbContext db, Guid userId)
+ {
+ var conversation = new ConversationEntity
+ {
+ Title = "测试会话",
+ UserId = userId,
+ };
+ db.Conversations.Add(conversation);
+ await db.SaveChangesAsync();
+ return conversation.Id;
+ }
+
+ private static async Task> CollectAsync(IAsyncEnumerable source)
+ {
+ var result = new List();
+ await foreach (var item in source) result.Add(item);
+ return result;
+ }
+}