feat: 完善 Agent 标准交互与安全运行时
- 接入 AG-UI 运行投影、Turn 时间线和审批隔离 - 增加 Agent Skill 冻结绑定与运行时消费闭环 - 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
@@ -129,7 +129,7 @@ public class ChatPersistDispatcher {
|
||||
payload.setUserId(userId);
|
||||
payload.setOperatorId(operatorId);
|
||||
payload.setOperateAt(operateAt);
|
||||
eventProducer.send(buildEvent(
|
||||
ChatPersistEvent event = buildEvent(
|
||||
UUID.randomUUID().toString(),
|
||||
ChatPersistEventType.SESSION_DELETED,
|
||||
sessionId,
|
||||
@@ -137,7 +137,9 @@ public class ChatPersistDispatcher {
|
||||
BigInteger.ZERO,
|
||||
operateAt,
|
||||
chatJsonSupport.toJson(payload)
|
||||
));
|
||||
);
|
||||
persistImmediately(event);
|
||||
eventProducer.send(event);
|
||||
}
|
||||
|
||||
private void appendMessage(ChatAppendMessageCommand command, ChatPersistEventType eventType) {
|
||||
|
||||
@@ -29,6 +29,26 @@ public interface ChatRoundOperateService {
|
||||
*/
|
||||
List<ChatMessageRecord> listVariants(BigInteger sessionId, BigInteger roundId);
|
||||
|
||||
/**
|
||||
* 查询轮次下未执行业务安全投影的答案版本,供完整会话批量投影使用。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param roundId 轮次 ID
|
||||
* @return 原始答案版本列表
|
||||
*/
|
||||
default List<ChatMessageRecord> listVariantsUnprojected(BigInteger sessionId, BigInteger roundId) {
|
||||
return listVariants(sessionId, roundId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对同一会话的答案版本执行一次批量业务安全投影。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param records 待投影答案版本
|
||||
*/
|
||||
default void projectVariants(BigInteger sessionId, List<ChatMessageRecord> records) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换指定轮次当前选中的答案版本。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 聊天会话删除与历史返回的业务扩展点。
|
||||
*/
|
||||
public interface ChatSessionExtension {
|
||||
|
||||
/**
|
||||
* 判断扩展是否处理指定会话类型。
|
||||
*
|
||||
* @param summary 会话摘要
|
||||
* @return 需要处理时为 true
|
||||
*/
|
||||
boolean supports(ChatSessionSummary summary);
|
||||
|
||||
/**
|
||||
* 在会话删除落库前同步处理关联资源。
|
||||
*
|
||||
* @param summary 会话摘要
|
||||
* @param userId 会话用户 ID
|
||||
* @param operatorId 操作人 ID
|
||||
*/
|
||||
default void beforeDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在会话删除分发成功后同步处理关联资源。
|
||||
*
|
||||
* @param summary 会话摘要
|
||||
* @param userId 会话用户 ID
|
||||
* @param operatorId 操作人 ID
|
||||
*/
|
||||
default void afterDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在历史消息返回前覆盖业务安全投影。
|
||||
*
|
||||
* @param summary 会话摘要
|
||||
* @param records 本次返回的消息集合
|
||||
*/
|
||||
default void projectMessages(ChatSessionSummary summary, List<ChatMessageRecord> records) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 按会话类型同步分发会话生命周期与历史投影扩展。
|
||||
*/
|
||||
@Component
|
||||
public class ChatSessionExtensionDispatcher {
|
||||
|
||||
private final List<ChatSessionExtension> extensions;
|
||||
|
||||
/**
|
||||
* 创建扩展分发器。
|
||||
*
|
||||
* @param extensions 当前应用注册的会话扩展
|
||||
*/
|
||||
public ChatSessionExtensionDispatcher(List<ChatSessionExtension> extensions) {
|
||||
this.extensions = extensions == null ? List.of() : List.copyOf(extensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在会话删除前同步执行匹配扩展。
|
||||
*
|
||||
* @param summary 会话摘要
|
||||
* @param userId 会话用户 ID
|
||||
* @param operatorId 操作人 ID
|
||||
*/
|
||||
public void beforeDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) {
|
||||
for (ChatSessionExtension extension : extensions) {
|
||||
if (extension.supports(summary)) {
|
||||
extension.beforeDelete(summary, userId, operatorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在会话删除分发成功后同步执行匹配扩展。
|
||||
*
|
||||
* @param summary 会话摘要
|
||||
* @param userId 会话用户 ID
|
||||
* @param operatorId 操作人 ID
|
||||
*/
|
||||
public void afterDelete(ChatSessionSummary summary, BigInteger userId, BigInteger operatorId) {
|
||||
for (ChatSessionExtension extension : extensions) {
|
||||
if (extension.supports(summary)) {
|
||||
extension.afterDelete(summary, userId, operatorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在消息返回前同步执行匹配扩展。
|
||||
*
|
||||
* @param summary 会话摘要
|
||||
* @param records 本次返回消息
|
||||
*/
|
||||
public void projectMessages(ChatSessionSummary summary, List<ChatMessageRecord> records) {
|
||||
for (ChatSessionExtension extension : extensions) {
|
||||
if (extension.supports(summary)) {
|
||||
extension.projectMessages(summary, records);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository;
|
||||
import tech.easyflow.chatlog.service.ChatHistoryQueryService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
@@ -12,13 +14,29 @@ import java.math.BigInteger;
|
||||
public class ChatHistoryQueryServiceImpl implements ChatHistoryQueryService {
|
||||
|
||||
private final ChatAnalyticalDBRepository chatAnalyticalDBRepository;
|
||||
private final ChatSessionQueryService chatSessionQueryService;
|
||||
private final ChatSessionExtensionDispatcher extensionDispatcher;
|
||||
|
||||
public ChatHistoryQueryServiceImpl(ChatAnalyticalDBRepository chatAnalyticalDBRepository) {
|
||||
/**
|
||||
* 创建归档历史查询服务。
|
||||
*
|
||||
* @param chatAnalyticalDBRepository 分析库仓储
|
||||
* @param chatSessionQueryService 会话摘要查询服务
|
||||
* @param extensionDispatcher 会话业务扩展分发器
|
||||
*/
|
||||
public ChatHistoryQueryServiceImpl(ChatAnalyticalDBRepository chatAnalyticalDBRepository,
|
||||
ChatSessionQueryService chatSessionQueryService,
|
||||
ChatSessionExtensionDispatcher extensionDispatcher) {
|
||||
this.chatAnalyticalDBRepository = chatAnalyticalDBRepository;
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.extensionDispatcher = extensionDispatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatHistoryPage queryHistoryMessages(BigInteger sessionId, ChatPageQuery query) {
|
||||
return chatAnalyticalDBRepository.queryHistory(sessionId, query);
|
||||
ChatHistoryPage page = chatAnalyticalDBRepository.queryHistory(sessionId, query);
|
||||
extensionDispatcher.projectMessages(
|
||||
chatSessionQueryService.getSessionSummary(sessionId), page.getRecords());
|
||||
return page;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.service.ChatRoundCommandService;
|
||||
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||
import tech.easyflow.chatlog.service.ChatRoundQueryService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
import tech.easyflow.chatlog.support.ChatConstants;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
@@ -23,6 +27,8 @@ public class ChatRoundOperateServiceImpl implements ChatRoundOperateService {
|
||||
|
||||
private final ChatRoundQueryService chatRoundQueryService;
|
||||
private final ChatRoundCommandService chatRoundCommandService;
|
||||
private ChatSessionQueryService chatSessionQueryService;
|
||||
private ChatSessionExtensionDispatcher extensionDispatcher;
|
||||
|
||||
public ChatRoundOperateServiceImpl(ChatRoundQueryService chatRoundQueryService,
|
||||
ChatRoundCommandService chatRoundCommandService) {
|
||||
@@ -30,6 +36,20 @@ public class ChatRoundOperateServiceImpl implements ChatRoundOperateService {
|
||||
this.chatRoundCommandService = chatRoundCommandService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟注入会话投影依赖,避免会话查询服务与轮次服务形成初始化环。
|
||||
*
|
||||
* @param chatSessionQueryService 会话查询服务
|
||||
* @param extensionDispatcher 会话扩展分发器
|
||||
*/
|
||||
@Autowired
|
||||
@Lazy
|
||||
public void setProjectionDependencies(ChatSessionQueryService chatSessionQueryService,
|
||||
ChatSessionExtensionDispatcher extensionDispatcher) {
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.extensionDispatcher = extensionDispatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord requireRegeneratableRound(BigInteger sessionId, BigInteger roundId) {
|
||||
ChatRoundRecord round = requireLatestRound(sessionId, roundId);
|
||||
@@ -42,6 +62,13 @@ public class ChatRoundOperateServiceImpl implements ChatRoundOperateService {
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> listVariants(BigInteger sessionId, BigInteger roundId) {
|
||||
List<ChatMessageRecord> variants = listVariantsUnprojected(sessionId, roundId);
|
||||
projectVariants(sessionId, variants);
|
||||
return variants;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> listVariantsUnprojected(BigInteger sessionId, BigInteger roundId) {
|
||||
ChatRoundRecord round = chatRoundQueryService.getRound(sessionId, roundId);
|
||||
if (round == null) {
|
||||
throw new BusinessException("轮次不存在");
|
||||
@@ -59,6 +86,17 @@ public class ChatRoundOperateServiceImpl implements ChatRoundOperateService {
|
||||
return variants;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void projectVariants(BigInteger sessionId, List<ChatMessageRecord> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (chatSessionQueryService == null || extensionDispatcher == null) {
|
||||
throw new IllegalStateException("聊天答案版本安全投影服务未初始化");
|
||||
}
|
||||
extensionDispatcher.projectMessages(chatSessionQueryService.getSessionSummary(sessionId), records);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMessageRecord selectVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) {
|
||||
ChatRoundRecord round = requireLatestRound(sessionId, roundId);
|
||||
@@ -81,6 +119,7 @@ public class ChatRoundOperateServiceImpl implements ChatRoundOperateService {
|
||||
selected.setSelectedVariantIndex(variantIndex);
|
||||
selected.setVariantCount(round.getVariantCount());
|
||||
selected.setSwitchable(true);
|
||||
projectVariants(sessionId, List.of(selected));
|
||||
return selected;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.service.ChatPersistDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatSessionCommandService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
@@ -12,9 +14,22 @@ import java.math.BigInteger;
|
||||
public class ChatSessionCommandServiceImpl implements ChatSessionCommandService {
|
||||
|
||||
private final ChatPersistDispatcher chatPersistDispatcher;
|
||||
private final ChatSessionQueryService chatSessionQueryService;
|
||||
private final ChatSessionExtensionDispatcher extensionDispatcher;
|
||||
|
||||
public ChatSessionCommandServiceImpl(ChatPersistDispatcher chatPersistDispatcher) {
|
||||
/**
|
||||
* 创建会话命令服务。
|
||||
*
|
||||
* @param chatPersistDispatcher 聊天持久化分发器
|
||||
* @param chatSessionQueryService 会话查询服务
|
||||
* @param extensionDispatcher 会话业务扩展分发器
|
||||
*/
|
||||
public ChatSessionCommandServiceImpl(ChatPersistDispatcher chatPersistDispatcher,
|
||||
ChatSessionQueryService chatSessionQueryService,
|
||||
ChatSessionExtensionDispatcher extensionDispatcher) {
|
||||
this.chatPersistDispatcher = chatPersistDispatcher;
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.extensionDispatcher = extensionDispatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -29,6 +44,9 @@ public class ChatSessionCommandServiceImpl implements ChatSessionCommandService
|
||||
|
||||
@Override
|
||||
public void deleteSession(BigInteger sessionId, BigInteger userId, BigInteger operatorId) {
|
||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||
extensionDispatcher.beforeDelete(summary, userId, operatorId);
|
||||
chatPersistDispatcher.deleteSession(sessionId, userId, operatorId);
|
||||
extensionDispatcher.afterDelete(summary, userId, operatorId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import tech.easyflow.chatlog.cache.ChatHotStateService;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
@@ -11,6 +12,7 @@ import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.*;
|
||||
@@ -22,6 +24,8 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
|
||||
private final MySqlChatLogRepository logRepository;
|
||||
private final MySqlChatLogTableManager tableManager;
|
||||
private final ChatHotStateService chatHotStateService;
|
||||
private ChatSessionExtensionDispatcher extensionDispatcher =
|
||||
new ChatSessionExtensionDispatcher(List.of());
|
||||
|
||||
public ChatSessionQueryServiceImpl(MySqlChatSessionRepository sessionRepository,
|
||||
MySqlChatLogRepository logRepository,
|
||||
@@ -33,6 +37,16 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
|
||||
this.chatHotStateService = chatHotStateService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置会话历史业务扩展分发器。
|
||||
*
|
||||
* @param extensionDispatcher 扩展分发器
|
||||
*/
|
||||
@Autowired
|
||||
public void setExtensionDispatcher(ChatSessionExtensionDispatcher extensionDispatcher) {
|
||||
this.extensionDispatcher = extensionDispatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
|
||||
return listSessions(userId, assistantId, null, query);
|
||||
@@ -97,22 +111,31 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
|
||||
);
|
||||
page.setRecords(records);
|
||||
page.setTotal(Math.max(total, query.getOffset() + records.size()));
|
||||
extensionDispatcher.projectMessages(summary, records);
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> listMainlineMessages(BigInteger sessionId) {
|
||||
return logRepository.listMainlineMessages(sessionId, tableManager.listRecentExistingMonths(3));
|
||||
ChatSessionSummary summary = getSessionSummary(sessionId);
|
||||
List<ChatMessageRecord> records =
|
||||
logRepository.listMainlineMessages(sessionId, tableManager.listRecentExistingMonths(3));
|
||||
extensionDispatcher.projectMessages(summary, records);
|
||||
return records;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> getRecentTail(BigInteger sessionId, int limit) {
|
||||
ChatSessionSummary summary = getSessionSummary(sessionId);
|
||||
List<ChatMessageRecord> cached = chatHotStateService.getSessionTail(sessionId);
|
||||
if (cached != null && isTailReliable(cached)) {
|
||||
return cached.subList(0, Math.min(limit, cached.size()));
|
||||
List<ChatMessageRecord> records = cached.subList(0, Math.min(limit, cached.size()));
|
||||
extensionDispatcher.projectMessages(summary, records);
|
||||
return records;
|
||||
}
|
||||
List<ChatMessageRecord> records = logRepository.listRecentTail(sessionId, tableManager.listRecentExistingMonths(3), limit);
|
||||
chatHotStateService.setSessionTail(sessionId, records);
|
||||
extensionDispatcher.projectMessages(summary, records);
|
||||
return records;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mockito;
|
||||
import tech.easyflow.chatlog.cache.ChatHotStateService;
|
||||
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link ChatPersistDispatcher} 会话删除可靠持久化顺序测试。
|
||||
*/
|
||||
public class ChatPersistDispatcherTest {
|
||||
|
||||
/**
|
||||
* 验证 MySQL 同步删除失败时不发送异步事件。
|
||||
*/
|
||||
@Test
|
||||
public void deleteShouldStopBeforeProducerWhenMysqlApplyFails() {
|
||||
Fixture fixture = fixture();
|
||||
Mockito.doThrow(new IllegalStateException("mysql unavailable"))
|
||||
.when(fixture.applyService).apply(Mockito.anyList());
|
||||
|
||||
Assert.assertThrows(BusinessException.class, () -> fixture.dispatcher.deleteSession(
|
||||
BigInteger.ONE, BigInteger.TWO, BigInteger.TWO));
|
||||
|
||||
Mockito.verify(fixture.eventProducer, Mockito.never()).send(Mockito.any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证消息发送失败发生在 MySQL 同步删除成功之后并继续向上抛出。
|
||||
*/
|
||||
@Test
|
||||
public void deleteShouldPersistBeforePropagatingProducerFailure() {
|
||||
Fixture fixture = fixture();
|
||||
Mockito.doThrow(new IllegalStateException("mq unavailable"))
|
||||
.when(fixture.eventProducer).send(Mockito.any());
|
||||
|
||||
Assert.assertThrows(IllegalStateException.class, () -> fixture.dispatcher.deleteSession(
|
||||
BigInteger.ONE, BigInteger.TWO, BigInteger.TWO));
|
||||
|
||||
InOrder order = Mockito.inOrder(fixture.applyService, fixture.eventProducer);
|
||||
order.verify(fixture.applyService).apply(Mockito.<List<ChatPersistEvent>>any());
|
||||
order.verify(fixture.eventProducer).send(Mockito.any());
|
||||
}
|
||||
|
||||
private Fixture fixture() {
|
||||
ChatHotStateService hotStateService = Mockito.mock(ChatHotStateService.class);
|
||||
ChatPersistEventProducer eventProducer = Mockito.mock(ChatPersistEventProducer.class);
|
||||
ChatPersistMySqlApplyService applyService = Mockito.mock(ChatPersistMySqlApplyService.class);
|
||||
ChatJsonSupport jsonSupport = Mockito.mock(ChatJsonSupport.class);
|
||||
Mockito.when(jsonSupport.toJson(Mockito.any())).thenReturn("{}");
|
||||
return new Fixture(eventProducer, applyService,
|
||||
new ChatPersistDispatcher(hotStateService, eventProducer, applyService, jsonSupport));
|
||||
}
|
||||
|
||||
private record Fixture(ChatPersistEventProducer eventProducer,
|
||||
ChatPersistMySqlApplyService applyService,
|
||||
ChatPersistDispatcher dispatcher) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.Mockito;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository;
|
||||
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link ChatHistoryQueryServiceImpl} 归档历史安全投影测试。
|
||||
*/
|
||||
public class ChatHistoryQueryServiceImplTest {
|
||||
|
||||
/**
|
||||
* 验证 UserCenter/Admin 归档历史返回前统一执行一次会话批量投影。
|
||||
*/
|
||||
@Test
|
||||
public void shouldProjectAnalyticalHistoryOnce() {
|
||||
ChatAnalyticalDBRepository repository = Mockito.mock(ChatAnalyticalDBRepository.class);
|
||||
ChatSessionQueryService sessionQueryService = Mockito.mock(ChatSessionQueryService.class);
|
||||
ChatSessionExtensionDispatcher dispatcher = Mockito.mock(ChatSessionExtensionDispatcher.class);
|
||||
ChatHistoryPage page = new ChatHistoryPage();
|
||||
page.setRecords(List.of(new ChatMessageRecord()));
|
||||
ChatSessionSummary summary = new ChatSessionSummary();
|
||||
Mockito.when(repository.queryHistory(Mockito.eq(BigInteger.ONE), Mockito.any())).thenReturn(page);
|
||||
Mockito.when(sessionQueryService.getSessionSummary(BigInteger.ONE)).thenReturn(summary);
|
||||
ChatHistoryQueryServiceImpl service =
|
||||
new ChatHistoryQueryServiceImpl(repository, sessionQueryService, dispatcher);
|
||||
|
||||
Assert.assertSame(page, service.queryHistoryMessages(BigInteger.ONE, new ChatPageQuery()));
|
||||
|
||||
Mockito.verify(dispatcher, Mockito.times(1)).projectMessages(summary, page.getRecords());
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.service.ChatRoundCommandService;
|
||||
import tech.easyflow.chatlog.service.ChatRoundQueryService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
import tech.easyflow.chatlog.support.ChatConstants;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
@@ -32,7 +34,8 @@ public class ChatRoundOperateServiceImplTest {
|
||||
queryService.latestRound = queryService.round;
|
||||
queryService.targetVariant = message(BigInteger.valueOf(3002), 2);
|
||||
FakeRoundCommandService commandService = new FakeRoundCommandService();
|
||||
ChatRoundOperateServiceImpl service = new ChatRoundOperateServiceImpl(queryService, commandService);
|
||||
ProjectionFixture projection = withProjection(new ChatRoundOperateServiceImpl(queryService, commandService));
|
||||
ChatRoundOperateServiceImpl service = projection.service;
|
||||
|
||||
ChatMessageRecord selected = service.selectVariant(
|
||||
BigInteger.valueOf(1001),
|
||||
@@ -49,6 +52,8 @@ public class ChatRoundOperateServiceImplTest {
|
||||
Assert.assertEquals(0, queryService.listRoundVariantsCalls);
|
||||
Assert.assertNotNull(commandService.selectedCommand);
|
||||
Assert.assertEquals(BigInteger.valueOf(3002), commandService.selectedCommand.getSelectedAssistantMessageId());
|
||||
org.mockito.Mockito.verify(projection.dispatcher).projectMessages(
|
||||
projection.summary, List.of(selected));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +65,9 @@ public class ChatRoundOperateServiceImplTest {
|
||||
queryService.round = round(BigInteger.valueOf(1001), BigInteger.valueOf(2001), 2, ChatConstants.ROUND_STATUS_READY);
|
||||
queryService.latestRound = queryService.round;
|
||||
queryService.variants = List.of(message(BigInteger.valueOf(3001), 1), message(BigInteger.valueOf(3002), 2));
|
||||
ChatRoundOperateServiceImpl service = new ChatRoundOperateServiceImpl(queryService, new FakeRoundCommandService());
|
||||
ProjectionFixture projection = withProjection(
|
||||
new ChatRoundOperateServiceImpl(queryService, new FakeRoundCommandService()));
|
||||
ChatRoundOperateServiceImpl service = projection.service;
|
||||
|
||||
List<ChatMessageRecord> variants = service.listVariants(BigInteger.valueOf(1001), BigInteger.valueOf(2001));
|
||||
|
||||
@@ -70,6 +77,7 @@ public class ChatRoundOperateServiceImplTest {
|
||||
Assert.assertEquals(Integer.valueOf(2), variant.getSelectedVariantIndex());
|
||||
Assert.assertEquals(Boolean.TRUE, variant.getSwitchable());
|
||||
}
|
||||
org.mockito.Mockito.verify(projection.dispatcher).projectMessages(projection.summary, variants);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +142,23 @@ public class ChatRoundOperateServiceImplTest {
|
||||
return round;
|
||||
}
|
||||
|
||||
private ProjectionFixture withProjection(ChatRoundOperateServiceImpl service) {
|
||||
ChatSessionQueryService sessionQueryService = org.mockito.Mockito.mock(ChatSessionQueryService.class);
|
||||
ChatSessionExtensionDispatcher dispatcher = org.mockito.Mockito.mock(ChatSessionExtensionDispatcher.class);
|
||||
tech.easyflow.chatlog.domain.dto.ChatSessionSummary summary =
|
||||
new tech.easyflow.chatlog.domain.dto.ChatSessionSummary();
|
||||
summary.setId(BigInteger.valueOf(1001));
|
||||
org.mockito.Mockito.when(sessionQueryService.getSessionSummary(BigInteger.valueOf(1001)))
|
||||
.thenReturn(summary);
|
||||
service.setProjectionDependencies(sessionQueryService, dispatcher);
|
||||
return new ProjectionFixture(service, dispatcher, summary);
|
||||
}
|
||||
|
||||
private record ProjectionFixture(ChatRoundOperateServiceImpl service,
|
||||
ChatSessionExtensionDispatcher dispatcher,
|
||||
tech.easyflow.chatlog.domain.dto.ChatSessionSummary summary) {
|
||||
}
|
||||
|
||||
private static ChatMessageRecord message(BigInteger id, int variantIndex) {
|
||||
ChatMessageRecord record = new ChatMessageRecord();
|
||||
record.setId(id);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mockito;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.service.ChatPersistDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* {@link ChatSessionCommandServiceImpl} 删除扩展顺序与失败传播测试。
|
||||
*/
|
||||
public class ChatSessionCommandServiceImplTest {
|
||||
|
||||
/**
|
||||
* 验证所有删除入口共享 before、删除、after 的固定顺序。
|
||||
*/
|
||||
@Test
|
||||
public void deleteShouldInvokeLifecycleHooksAroundPersistDispatch() {
|
||||
Fixture fixture = fixture();
|
||||
|
||||
fixture.service.deleteSession(BigInteger.ONE, BigInteger.TWO, BigInteger.TWO);
|
||||
|
||||
InOrder order = Mockito.inOrder(fixture.extensions, fixture.persistDispatcher);
|
||||
order.verify(fixture.extensions).beforeDelete(fixture.summary, BigInteger.TWO, BigInteger.TWO);
|
||||
order.verify(fixture.persistDispatcher).deleteSession(BigInteger.ONE, BigInteger.TWO, BigInteger.TWO);
|
||||
order.verify(fixture.extensions).afterDelete(fixture.summary, BigInteger.TWO, BigInteger.TWO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证会话删除失败时不会提前执行 after hook 标记关联资源删除。
|
||||
*/
|
||||
@Test
|
||||
public void deleteFailureShouldNotInvokeAfterHook() {
|
||||
Fixture fixture = fixture();
|
||||
Mockito.doThrow(new IllegalStateException("mq unavailable"))
|
||||
.when(fixture.persistDispatcher).deleteSession(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
|
||||
Assert.assertThrows(IllegalStateException.class,
|
||||
() -> fixture.service.deleteSession(BigInteger.ONE, BigInteger.TWO, BigInteger.TWO));
|
||||
|
||||
Mockito.verify(fixture.extensions, Mockito.never())
|
||||
.afterDelete(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
private Fixture fixture() {
|
||||
ChatPersistDispatcher persistDispatcher = Mockito.mock(ChatPersistDispatcher.class);
|
||||
ChatSessionQueryService queryService = Mockito.mock(ChatSessionQueryService.class);
|
||||
ChatSessionExtensionDispatcher extensions = Mockito.mock(ChatSessionExtensionDispatcher.class);
|
||||
ChatSessionSummary summary = new ChatSessionSummary();
|
||||
summary.setId(BigInteger.ONE);
|
||||
Mockito.when(queryService.getSessionSummary(BigInteger.ONE)).thenReturn(summary);
|
||||
return new Fixture(persistDispatcher, extensions, summary,
|
||||
new ChatSessionCommandServiceImpl(persistDispatcher, queryService, extensions));
|
||||
}
|
||||
|
||||
private record Fixture(ChatPersistDispatcher persistDispatcher,
|
||||
ChatSessionExtensionDispatcher extensions,
|
||||
ChatSessionSummary summary,
|
||||
ChatSessionCommandServiceImpl service) {
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
import tech.easyflow.chatlog.service.ChatSessionExtensionDispatcher;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.YearMonth;
|
||||
@@ -144,6 +145,28 @@ public class ChatSessionQueryServiceImplTest {
|
||||
Assert.assertEquals(4, page.getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证消息分页返回前仅执行一次会话级批量安全投影。
|
||||
*/
|
||||
@Test
|
||||
public void pageMainlineMessagesShouldProjectRecordsOnce() {
|
||||
FakeSessionRepository sessionRepository = new FakeSessionRepository();
|
||||
sessionRepository.summary = session(BigInteger.valueOf(2003), 1);
|
||||
FakeLogRepository logRepository = new FakeLogRepository();
|
||||
logRepository.records = List.of(message(5001));
|
||||
ChatSessionQueryServiceImpl service = new ChatSessionQueryServiceImpl(
|
||||
sessionRepository, logRepository,
|
||||
new FakeTableManager(List.of(YearMonth.of(2026, 5))), new FakeHotStateService());
|
||||
ChatSessionExtensionDispatcher dispatcher =
|
||||
org.mockito.Mockito.mock(ChatSessionExtensionDispatcher.class);
|
||||
service.setExtensionDispatcher(dispatcher);
|
||||
|
||||
ChatHistoryPage page = service.pageMainlineMessages(BigInteger.valueOf(2003), new ChatPageQuery());
|
||||
|
||||
org.mockito.Mockito.verify(dispatcher, org.mockito.Mockito.times(1))
|
||||
.projectMessages(sessionRepository.summary, page.getRecords());
|
||||
}
|
||||
|
||||
private static ChatSessionSummary session(BigInteger id, int messageCount) {
|
||||
ChatSessionSummary summary = new ChatSessionSummary();
|
||||
summary.setId(id);
|
||||
|
||||
Reference in New Issue
Block a user