feat: 完成管理端聊天工作台收口

- 新增管理端聊天工作台与会话级额外知识库持久化

- 补齐发布态聊天、历史会话只读判断与答案版本切换

- 新增 chat_round 热数据与主线消息读取支撑
This commit is contained in:
2026-05-14 20:22:46 +08:00
parent 2ad8935a61
commit 47c2bad839
63 changed files with 8609 additions and 136 deletions

View File

@@ -1,16 +1,22 @@
package tech.easyflow.chatlog.service;
import org.slf4j.MDC;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.cache.ChatHotStateService;
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
import tech.easyflow.chatlog.domain.event.ChatPersistEventType;
import tech.easyflow.chatlog.domain.event.payload.ChatSessionDeletePayload;
import tech.easyflow.chatlog.domain.event.payload.ChatSessionRenamePayload;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.Date;
@@ -19,21 +25,26 @@ import java.util.UUID;
@Service
public class ChatPersistDispatcher {
private static final Logger log = LoggerFactory.getLogger(ChatPersistDispatcher.class);
private final ChatHotStateService chatHotStateService;
private final ChatPersistEventProducer eventProducer;
private final ChatPersistMySqlApplyService mySqlApplyService;
private final ChatJsonSupport chatJsonSupport;
public ChatPersistDispatcher(ChatHotStateService chatHotStateService,
ChatPersistEventProducer eventProducer,
ChatPersistMySqlApplyService mySqlApplyService,
ChatJsonSupport chatJsonSupport) {
this.chatHotStateService = chatHotStateService;
this.eventProducer = eventProducer;
this.mySqlApplyService = mySqlApplyService;
this.chatJsonSupport = chatJsonSupport;
}
public ChatSessionSummary createOrTouchSession(ChatSessionUpsertCommand command) {
ChatSessionSummary summary = chatHotStateService.touchSession(command);
eventProducer.send(buildEvent(
ChatPersistEvent event = buildEvent(
UUID.randomUUID().toString(),
ChatPersistEventType.SESSION_PREPARED,
command.getSessionId(),
@@ -41,10 +52,43 @@ public class ChatPersistDispatcher {
command.getAssistantId(),
command.getOperateAt(),
chatJsonSupport.toJson(command)
));
);
persistImmediately(event);
eventProducer.send(event);
return summary;
}
public ChatRoundRecord createOrTouchRound(ChatRoundUpsertCommand command) {
ChatRoundRecord record = chatHotStateService.createOrTouchRound(command);
ChatPersistEvent event = buildEvent(
UUID.randomUUID().toString(),
ChatPersistEventType.ROUND_UPSERTED,
command.getSessionId(),
BigInteger.ZERO,
BigInteger.ZERO,
command.getOperateAt(),
chatJsonSupport.toJson(command)
);
persistImmediately(event);
eventProducer.send(event);
return record;
}
public void selectRoundVariant(ChatRoundSelectCommand command) {
chatHotStateService.selectVariant(command);
ChatPersistEvent event = buildEvent(
UUID.randomUUID().toString(),
ChatPersistEventType.ROUND_VARIANT_SELECTED,
command.getSessionId(),
BigInteger.ZERO,
BigInteger.ZERO,
command.getOperateAt(),
chatJsonSupport.toJson(command)
);
persistImmediately(event);
eventProducer.send(event);
}
public void appendUserMessage(ChatAppendMessageCommand command) {
appendMessage(command, ChatPersistEventType.USER_MESSAGE_APPENDED);
}
@@ -96,7 +140,7 @@ public class ChatPersistDispatcher {
private void appendMessage(ChatAppendMessageCommand command, ChatPersistEventType eventType) {
chatHotStateService.appendMessage(command);
eventProducer.send(buildEvent(
ChatPersistEvent event = buildEvent(
eventId("message", command.getMessageId()),
eventType,
command.getSessionId(),
@@ -104,7 +148,27 @@ public class ChatPersistDispatcher {
command.getAssistantId(),
command.getCreated(),
chatJsonSupport.toJson(command)
));
);
persistImmediately(event);
eventProducer.send(event);
}
/**
* 先同步写入 MySQL再发送异步事件保证会话列表和版本切换读取有确定来源。
*
* @param event 持久化事件
*/
private void persistImmediately(ChatPersistEvent event) {
try {
mySqlApplyService.apply(java.util.List.of(event));
} catch (RuntimeException ex) {
log.error("聊天记录同步写入 MySQL 失败eventId={}, eventType={}, sessionId={}",
event == null ? null : event.getEventId(),
event == null ? null : event.getEventType(),
event == null ? null : event.getSessionId(),
ex);
throw new BusinessException("聊天记录持久化失败,请稍后重试");
}
}
private ChatPersistEvent buildEvent(String eventId,

View File

@@ -3,6 +3,8 @@ package tech.easyflow.chatlog.service;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
import tech.easyflow.chatlog.domain.command.ChatSessionSummaryCommand;
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
@@ -11,7 +13,9 @@ import tech.easyflow.chatlog.domain.event.payload.ChatSessionDeletePayload;
import tech.easyflow.chatlog.domain.event.payload.ChatSessionRenamePayload;
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository;
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager;
import tech.easyflow.chatlog.repository.mysql.MySqlChatRoundRepository;
import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
import tech.easyflow.chatlog.support.ChatConstants;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import java.math.BigInteger;
@@ -30,15 +34,18 @@ public class ChatPersistMySqlApplyService {
private final MySqlChatSessionRepository sessionRepository;
private final MySqlChatLogRepository logRepository;
private final MySqlChatRoundRepository roundRepository;
private final MySqlChatLogTableManager tableManager;
private final ChatJsonSupport chatJsonSupport;
public ChatPersistMySqlApplyService(MySqlChatSessionRepository sessionRepository,
MySqlChatLogRepository logRepository,
MySqlChatRoundRepository roundRepository,
MySqlChatLogTableManager tableManager,
ChatJsonSupport chatJsonSupport) {
this.sessionRepository = sessionRepository;
this.logRepository = logRepository;
this.roundRepository = roundRepository;
this.tableManager = tableManager;
this.chatJsonSupport = chatJsonSupport;
}
@@ -50,6 +57,8 @@ public class ChatPersistMySqlApplyService {
}
Map<BigInteger, ChatSessionUpsertCommand> sessionUpserts = new LinkedHashMap<>();
Map<BigInteger, ChatRoundUpsertCommand> roundUpserts = new LinkedHashMap<>();
List<ChatRoundSelectCommand> roundSelections = new ArrayList<>();
List<ChatAppendMessageCommand> appendCommands = new ArrayList<>();
Map<BigInteger, ChatSessionSummaryCommand> summaryCommands = new LinkedHashMap<>();
List<ChatSessionRenamePayload> renamePayloads = new ArrayList<>();
@@ -67,6 +76,18 @@ public class ChatPersistMySqlApplyService {
sessionUpserts.put(command.getSessionId(), command);
}
}
case ROUND_UPSERTED -> {
ChatRoundUpsertCommand command = chatJsonSupport.fromJson(event.getPayload(), ChatRoundUpsertCommand.class);
if (command != null && command.getRoundId() != null) {
roundUpserts.put(command.getRoundId(), command);
}
}
case ROUND_VARIANT_SELECTED -> {
ChatRoundSelectCommand command = chatJsonSupport.fromJson(event.getPayload(), ChatRoundSelectCommand.class);
if (command != null && command.getRoundId() != null) {
roundSelections.add(command);
}
}
case USER_MESSAGE_APPENDED, ASSISTANT_MESSAGE_APPENDED -> {
ChatAppendMessageCommand command = chatJsonSupport.fromJson(event.getPayload(), ChatAppendMessageCommand.class);
if (command == null || command.getSessionId() == null || command.getMessageId() == null) {
@@ -96,6 +117,9 @@ public class ChatPersistMySqlApplyService {
if (!sessionUpserts.isEmpty()) {
sessionRepository.createOrTouchBatch(new ArrayList<>(sessionUpserts.values()));
}
if (!roundUpserts.isEmpty()) {
roundRepository.createOrTouchBatch(new ArrayList<>(roundUpserts.values()));
}
if (!months.isEmpty()) {
for (YearMonth month : months) {
tableManager.ensureMonthTable(month);
@@ -113,6 +137,9 @@ public class ChatPersistMySqlApplyService {
}
sessionRepository.updateSummaries(new ArrayList<>(summaryCommands.values()));
}
if (!roundSelections.isEmpty()) {
roundRepository.selectVariants(roundSelections);
}
if (!renamePayloads.isEmpty()) {
sessionRepository.renameSessions(renamePayloads);
}
@@ -127,15 +154,26 @@ public class ChatPersistMySqlApplyService {
ChatSessionSummaryCommand created = new ChatSessionSummaryCommand();
created.setSessionId(command.getSessionId());
created.setUserId(command.getUserId());
created.setLastMessageAt(null);
created.setAccessAt(null);
created.setModifiedAt(null);
created.setMessageIncrement(0);
return created;
});
summary.setMessageIncrement(summary.getMessageIncrement() + 1);
if (summary.getLastMessageAt() == null || !command.getCreated().before(summary.getLastMessageAt())) {
if (ChatConstants.MESSAGE_KIND_ASSISTANT_VARIANT.equals(command.getMessageKind())
&& command.getVariantIndex() != null
&& command.getVariantIndex() > 1) {
summary.setMessageIncrement(Math.max(summary.getMessageIncrement() - 1, 0));
}
Date commandCreated = defaultDate(command.getCreated());
if (summary.getLastMessageAt() == null || !commandCreated.before(summary.getLastMessageAt())) {
summary.setLastSenderId(command.getSenderId());
summary.setLastSenderName(command.getSenderName());
summary.setLastMessagePreview(trimPreview(command.getContentText()));
summary.setLastMessageAt(command.getCreated());
summary.setLastMessageAt(commandCreated);
summary.setAccessAt(commandCreated);
summary.setModifiedAt(commandCreated);
summary.setOperatorId(command.getCreatedBy());
}
}

View File

@@ -0,0 +1,26 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
/**
* 聊天轮次写服务。
*/
public interface ChatRoundCommandService {
/**
* 创建或更新轮次聚合。
*
* @param command 轮次命令
* @return 最新轮次记录
*/
ChatRoundRecord createOrTouchRound(ChatRoundUpsertCommand command);
/**
* 切换轮次当前选中的答案版本。
*
* @param command 切换命令
*/
void selectVariant(ChatRoundSelectCommand command);
}

View File

@@ -0,0 +1,42 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
import java.math.BigInteger;
import java.util.List;
/**
* 聊天轮次业务操作服务。
*/
public interface ChatRoundOperateService {
/**
* 校验并返回允许重答的轮次。
*
* @param sessionId 会话 ID
* @param roundId 轮次 ID
* @return 轮次记录
*/
ChatRoundRecord requireRegeneratableRound(BigInteger sessionId, BigInteger roundId);
/**
* 查询轮次下所有答案版本。
*
* @param sessionId 会话 ID
* @param roundId 轮次 ID
* @return 答案版本列表
*/
List<ChatMessageRecord> listVariants(BigInteger sessionId, BigInteger roundId);
/**
* 切换指定轮次当前选中的答案版本。
*
* @param sessionId 会话 ID
* @param roundId 轮次 ID
* @param variantIndex 目标版本序号
* @param operatorId 操作人
* @return 选中的答案消息
*/
ChatMessageRecord selectVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId);
}

View File

@@ -0,0 +1,57 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
import java.math.BigInteger;
import java.util.List;
/**
* 聊天轮次读服务。
*/
public interface ChatRoundQueryService {
/**
* 查询会话最新轮次。
*
* @param sessionId 会话 ID
* @return 最新轮次
*/
ChatRoundRecord getLatestRound(BigInteger sessionId);
/**
* 查询指定轮次。
*
* @param sessionId 会话 ID
* @param roundId 轮次 ID
* @return 轮次记录
*/
ChatRoundRecord getRound(BigInteger sessionId, BigInteger roundId);
/**
* 查询轮次下所有助手答案版本。
*
* @param sessionId 会话 ID
* @param roundId 轮次 ID
* @return 答案版本列表
*/
List<ChatMessageRecord> listRoundVariants(BigInteger sessionId, BigInteger roundId);
/**
* 查询轮次下指定答案版本。
*
* @param sessionId 会话 ID
* @param roundId 轮次 ID
* @param variantIndex 答案版本序号
* @return 答案版本记录
*/
ChatMessageRecord getRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex);
/**
* 判断会话是否已经启用轮次模型。
*
* @param sessionId 会话 ID
* @return 是否存在轮次
*/
boolean hasRounds(BigInteger sessionId);
}

View File

@@ -1,6 +1,7 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
@@ -18,5 +19,22 @@ public interface ChatSessionQueryService {
ChatSessionSummary getSessionSummary(BigInteger sessionId);
/**
* 分页查询当前会话的主线可见消息。
*
* @param sessionId 会话 ID
* @param query 分页参数
* @return 主线消息分页
*/
ChatHistoryPage pageMainlineMessages(BigInteger sessionId, ChatPageQuery query);
/**
* 查询当前会话的全部主线可见消息。
*
* @param sessionId 会话 ID
* @return 主线消息列表
*/
List<ChatMessageRecord> listMainlineMessages(BigInteger sessionId);
List<ChatMessageRecord> getRecentTail(BigInteger sessionId, int limit);
}

View File

@@ -0,0 +1,31 @@
package tech.easyflow.chatlog.service.impl;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
import tech.easyflow.chatlog.service.ChatPersistDispatcher;
import tech.easyflow.chatlog.service.ChatRoundCommandService;
/**
* 聊天轮次写服务实现。
*/
@Service
public class ChatRoundCommandServiceImpl implements ChatRoundCommandService {
private final ChatPersistDispatcher chatPersistDispatcher;
public ChatRoundCommandServiceImpl(ChatPersistDispatcher chatPersistDispatcher) {
this.chatPersistDispatcher = chatPersistDispatcher;
}
@Override
public ChatRoundRecord createOrTouchRound(ChatRoundUpsertCommand command) {
return chatPersistDispatcher.createOrTouchRound(command);
}
@Override
public void selectVariant(ChatRoundSelectCommand command) {
chatPersistDispatcher.selectRoundVariant(command);
}
}

View File

@@ -0,0 +1,101 @@
package tech.easyflow.chatlog.service.impl;
import org.springframework.stereotype.Service;
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.support.ChatConstants;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* 聊天轮次业务操作服务实现。
*/
@Service
public class ChatRoundOperateServiceImpl implements ChatRoundOperateService {
private final ChatRoundQueryService chatRoundQueryService;
private final ChatRoundCommandService chatRoundCommandService;
public ChatRoundOperateServiceImpl(ChatRoundQueryService chatRoundQueryService,
ChatRoundCommandService chatRoundCommandService) {
this.chatRoundQueryService = chatRoundQueryService;
this.chatRoundCommandService = chatRoundCommandService;
}
@Override
public ChatRoundRecord requireRegeneratableRound(BigInteger sessionId, BigInteger roundId) {
ChatRoundRecord round = requireLatestRound(sessionId, roundId);
if (round.getSelectedAssistantMessageId() == null || round.getSelectedVariantIndex() == null
|| round.getSelectedVariantIndex() <= 0) {
throw new BusinessException("当前轮次暂无可重答的回答");
}
return round;
}
@Override
public List<ChatMessageRecord> listVariants(BigInteger sessionId, BigInteger roundId) {
ChatRoundRecord round = chatRoundQueryService.getRound(sessionId, roundId);
if (round == null) {
throw new BusinessException("轮次不存在");
}
ChatRoundRecord latestRound = chatRoundQueryService.getLatestRound(sessionId);
boolean switchable = latestRound != null
&& Objects.equals(latestRound.getId(), round.getId())
&& !ChatConstants.ROUND_STATUS_LOCKED.equalsIgnoreCase(round.getStatus());
List<ChatMessageRecord> variants = new ArrayList<>(chatRoundQueryService.listRoundVariants(sessionId, roundId));
for (ChatMessageRecord variant : variants) {
variant.setVariantCount(round.getVariantCount());
variant.setSelectedVariantIndex(round.getSelectedVariantIndex());
variant.setSwitchable(switchable);
}
return variants;
}
@Override
public ChatMessageRecord selectVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) {
ChatRoundRecord round = requireLatestRound(sessionId, roundId);
if (variantIndex == null || variantIndex <= 0) {
throw new BusinessException("目标答案版本无效");
}
ChatMessageRecord selected = chatRoundQueryService.getRoundVariant(sessionId, roundId, variantIndex);
if (selected == null) {
throw new BusinessException("目标答案版本不存在");
}
ChatRoundSelectCommand command = new ChatRoundSelectCommand();
command.setSessionId(sessionId);
command.setRoundId(roundId);
command.setSelectedVariantIndex(variantIndex);
command.setSelectedAssistantMessageId(selected.getId());
command.setSelectedAssistantMessage(selected);
command.setOperatorId(operatorId);
chatRoundCommandService.selectVariant(command);
selected.setSelectedVariantIndex(variantIndex);
selected.setVariantCount(round.getVariantCount());
selected.setSwitchable(true);
return selected;
}
private ChatRoundRecord requireLatestRound(BigInteger sessionId, BigInteger roundId) {
ChatRoundRecord round = chatRoundQueryService.getRound(sessionId, roundId);
if (round == null) {
throw new BusinessException("轮次不存在");
}
ChatRoundRecord latestRound = chatRoundQueryService.getLatestRound(sessionId);
if (latestRound == null || !Objects.equals(latestRound.getId(), round.getId())) {
throw new BusinessException("当前轮次已有后续对话,不支持切换答案版本");
}
if (ChatConstants.ROUND_STATUS_LOCKED.equalsIgnoreCase(round.getStatus())) {
throw new BusinessException("当前轮次已有后续对话,不支持切换答案版本");
}
return round;
}
}

View File

@@ -0,0 +1,76 @@
package tech.easyflow.chatlog.service.impl;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.cache.ChatHotStateService;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository;
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager;
import tech.easyflow.chatlog.repository.mysql.MySqlChatRoundRepository;
import tech.easyflow.chatlog.service.ChatRoundQueryService;
import java.math.BigInteger;
import java.util.List;
/**
* 聊天轮次读服务实现。
*/
@Service
public class ChatRoundQueryServiceImpl implements ChatRoundQueryService {
private final MySqlChatRoundRepository roundRepository;
private final MySqlChatLogRepository logRepository;
private final MySqlChatLogTableManager tableManager;
private final ChatHotStateService chatHotStateService;
public ChatRoundQueryServiceImpl(MySqlChatRoundRepository roundRepository,
MySqlChatLogRepository logRepository,
MySqlChatLogTableManager tableManager,
ChatHotStateService chatHotStateService) {
this.roundRepository = roundRepository;
this.logRepository = logRepository;
this.tableManager = tableManager;
this.chatHotStateService = chatHotStateService;
}
@Override
public ChatRoundRecord getLatestRound(BigInteger sessionId) {
ChatRoundRecord cached = chatHotStateService.getLatestRound(sessionId);
if (cached != null) {
return cached;
}
ChatRoundRecord record = roundRepository.findLatestRound(sessionId);
if (record != null) {
chatHotStateService.cacheRound(record);
}
return record;
}
@Override
public ChatRoundRecord getRound(BigInteger sessionId, BigInteger roundId) {
ChatRoundRecord cached = chatHotStateService.getRound(sessionId, roundId);
if (cached != null) {
return cached;
}
ChatRoundRecord record = roundRepository.findRound(sessionId, roundId);
if (record != null) {
chatHotStateService.cacheRound(record);
}
return record;
}
@Override
public List<ChatMessageRecord> listRoundVariants(BigInteger sessionId, BigInteger roundId) {
return logRepository.listRoundVariants(sessionId, roundId, tableManager.listRecentExistingMonths(3));
}
@Override
public ChatMessageRecord getRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex) {
return logRepository.findRoundVariant(sessionId, roundId, variantIndex, tableManager.listRecentExistingMonths(3));
}
@Override
public boolean hasRounds(BigInteger sessionId) {
return roundRepository.existsRounds(sessionId);
}
}

View File

@@ -2,6 +2,7 @@ package tech.easyflow.chatlog.service.impl;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.cache.ChatHotStateService;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
@@ -12,7 +13,12 @@ import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import java.math.BigInteger;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@Service
public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
@@ -34,21 +40,7 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
@Override
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
if (assistantId == null) {
List<BigInteger> sessionIds = chatHotStateService.listSessionIds(userId, query.getOffset(), query.getPageSize());
if (!sessionIds.isEmpty()) {
List<ChatSessionSummary> cached = chatHotStateService.getSessionSummaries(sessionIds);
if (cached.size() == sessionIds.size()) {
return cached;
}
}
List<ChatSessionSummary> sessions = sessionRepository.listSessions(userId, null, query);
chatHotStateService.cacheSessionSummaries(sessions);
return sessions;
}
List<ChatSessionSummary> sessions = sessionRepository.listSessions(userId, assistantId, query);
chatHotStateService.cacheSessionSummaries(sessions);
return sessions;
return sessionRepository.listSessions(userId, assistantId, query);
}
@Override
@@ -62,21 +54,6 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
page.setPageNumber(query.getPageNumber());
page.setPageSize(query.getPageSize());
if (assistantId == null && chatHotStateService.hasSessionIndex(userId)) {
List<BigInteger> sessionIds = chatHotStateService.listSessionIds(userId, query.getOffset(), query.getPageSize());
if (sessionIds.isEmpty()) {
page.setTotal(chatHotStateService.countSessions(userId));
page.setRecords(List.of());
return page;
}
List<ChatSessionSummary> cached = chatHotStateService.getSessionSummaries(sessionIds);
if (cached.size() == sessionIds.size()) {
page.setTotal(chatHotStateService.countSessions(userId));
page.setRecords(cached);
return page;
}
}
page.setTotal(sessionRepository.countSessions(userId, assistantId));
page.setRecords(listSessions(userId, assistantId, query));
return page;
@@ -95,14 +72,74 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
return summary;
}
@Override
public ChatHistoryPage pageMainlineMessages(BigInteger sessionId, ChatPageQuery query) {
ChatHistoryPage page = new ChatHistoryPage();
page.setPageNumber(query.getPageNumber());
page.setPageSize(query.getPageSize());
ChatSessionSummary summary = getSessionSummary(sessionId);
long total = summary == null || summary.getMessageCount() == null ? 0L : summary.getMessageCount();
List<ChatMessageRecord> records = logRepository.listMainlineMessages(
sessionId,
tableManager.listRecentExistingMonths(3),
query.getOffset(),
Math.toIntExact(query.getPageSize())
);
page.setRecords(records);
page.setTotal(Math.max(total, query.getOffset() + records.size()));
return page;
}
@Override
public List<ChatMessageRecord> listMainlineMessages(BigInteger sessionId) {
return logRepository.listMainlineMessages(sessionId, tableManager.listRecentExistingMonths(3));
}
@Override
public List<ChatMessageRecord> getRecentTail(BigInteger sessionId, int limit) {
List<ChatMessageRecord> cached = chatHotStateService.getSessionTail(sessionId);
if (cached != null) {
if (cached != null && isTailReliable(cached)) {
return cached.subList(0, Math.min(limit, cached.size()));
}
List<ChatMessageRecord> records = logRepository.listRecentTail(sessionId, tableManager.listRecentExistingMonths(3), limit);
chatHotStateService.setSessionTail(sessionId, records);
return records;
}
/**
* 校验 Redis tail 是否符合当前主线版本语义,防止过期选中版本把可见回答过滤掉。
*
* @param records Redis tail 消息
* @return true 表示可直接使用缓存
*/
private boolean isTailReliable(List<ChatMessageRecord> records) {
Map<BigInteger, Integer> selectedVariantByRound = new LinkedHashMap<>();
Map<BigInteger, Set<Integer>> assistantVariantsByRound = new LinkedHashMap<>();
for (ChatMessageRecord record : records) {
if (record == null || record.getRoundId() == null) {
continue;
}
Integer selectedVariantIndex = record.getSelectedVariantIndex();
if (selectedVariantIndex != null && selectedVariantIndex > 0) {
Integer previous = selectedVariantByRound.putIfAbsent(record.getRoundId(), selectedVariantIndex);
if (previous != null && !Objects.equals(previous, selectedVariantIndex)) {
return false;
}
}
if ("assistant".equalsIgnoreCase(record.getSenderRole())
&& record.getVariantIndex() != null
&& record.getVariantIndex() > 0) {
assistantVariantsByRound
.computeIfAbsent(record.getRoundId(), key -> new LinkedHashSet<>())
.add(record.getVariantIndex());
}
}
for (Map.Entry<BigInteger, Integer> entry : selectedVariantByRound.entrySet()) {
Set<Integer> visibleVariants = assistantVariantsByRound.get(entry.getKey());
if (visibleVariants != null && !visibleVariants.isEmpty() && !visibleVariants.contains(entry.getValue())) {
return false;
}
}
return true;
}
}

View File

@@ -4,11 +4,19 @@ import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionExtPayload;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.service.ChatPersistDispatcher;
import tech.easyflow.chatlog.service.ChatRoundOperateService;
import tech.easyflow.chatlog.service.ChatRoundQueryService;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.chatlog.support.ChatConstants;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
import tech.easyflow.core.runtime.ChatRuntimeHistoryPayloadHelper;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.core.runtime.ChatRuntimeListener;
@@ -27,12 +35,21 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
private final SnowFlakeIDKeyGenerator idGenerator = new SnowFlakeIDKeyGenerator();
private final ChatPersistDispatcher chatPersistDispatcher;
private final ChatRoundOperateService chatRoundOperateService;
private final ChatRoundQueryService chatRoundQueryService;
private final ChatSessionQueryService chatSessionQueryService;
private final ChatJsonSupport chatJsonSupport;
public ChatlogRuntimeListener(ChatPersistDispatcher chatPersistDispatcher,
ChatSessionQueryService chatSessionQueryService) {
ChatRoundOperateService chatRoundOperateService,
ChatRoundQueryService chatRoundQueryService,
ChatSessionQueryService chatSessionQueryService,
ChatJsonSupport chatJsonSupport) {
this.chatPersistDispatcher = chatPersistDispatcher;
this.chatRoundOperateService = chatRoundOperateService;
this.chatRoundQueryService = chatRoundQueryService;
this.chatSessionQueryService = chatSessionQueryService;
this.chatJsonSupport = chatJsonSupport;
}
@Override
@@ -48,6 +65,7 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
command.setAssistantCode(context.getAssistantCode());
command.setAssistantName(context.getAssistantName());
command.setTitle(context.getSessionTitle());
command.setExtJson(resolveExtJson(context));
command.setOperatorId(defaultNumber(context.getUserId()));
chatPersistDispatcher.createOrTouchSession(command);
} catch (RuntimeException ex) {
@@ -58,6 +76,9 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
@Override
public void onUserMessage(ChatRuntimeContext context, ChatRuntimeMessage message) {
try {
if (prepareRoundContext(context, message)) {
return;
}
chatPersistDispatcher.appendUserMessage(toAppendCommand(context, message));
} catch (RuntimeException ex) {
throw persistFailed(ex);
@@ -67,7 +88,36 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
@Override
public void onAssistantCompleted(ChatRuntimeContext context, ChatRuntimeMessage message) {
try {
applyAssistantRoundMetadata(context, message);
chatPersistDispatcher.appendAssistantMessage(toAppendCommand(context, message));
chatPersistDispatcher.createOrTouchRound(buildAssistantCompletedRoundCommand(context, message));
} catch (RuntimeException ex) {
throw persistFailed(ex);
}
}
@Override
public void onChatFailed(ChatRuntimeContext context, Throwable throwable) {
try {
BigInteger roundId = resolveNumber(context, ChatRuntimeExtKeys.CURRENT_ROUND_ID);
if (context == null || context.getSessionId() == null || roundId == null) {
return;
}
ChatRoundRecord currentRound = chatRoundQueryService.getRound(context.getSessionId(), roundId);
if (currentRound == null) {
return;
}
ChatRoundUpsertCommand command = new ChatRoundUpsertCommand();
command.setRoundId(currentRound.getId());
command.setSessionId(currentRound.getSessionId());
command.setRoundNo(currentRound.getRoundNo());
command.setUserMessageId(currentRound.getUserMessageId());
command.setSelectedAssistantMessageId(currentRound.getSelectedAssistantMessageId());
command.setSelectedVariantIndex(currentRound.getSelectedVariantIndex());
command.setVariantCount(currentRound.getVariantCount());
command.setStatus(ChatConstants.ROUND_STATUS_READY);
command.setOperatorId(defaultNumber(context.getUserId()));
chatPersistDispatcher.createOrTouchRound(command);
} catch (RuntimeException ex) {
throw persistFailed(ex);
}
@@ -78,7 +128,15 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
if (context == null || context.getSessionId() == null || limit <= 0) {
return Collections.emptyList();
}
List<ChatMessageRecord> records = new ArrayList<>(chatSessionQueryService.getRecentTail(context.getSessionId(), limit));
BigInteger regenerateRoundId = resolveNumber(context, ChatRuntimeExtKeys.REGENERATE_ROUND_ID);
int queryLimit = regenerateRoundId == null ? limit : limit + 4;
List<ChatMessageRecord> records = new ArrayList<>(chatSessionQueryService.getRecentTail(context.getSessionId(), queryLimit));
if (regenerateRoundId != null) {
records.removeIf(record -> regenerateRoundId.equals(record.getRoundId()));
if (records.size() > limit) {
records = new ArrayList<>(records.subList(0, limit));
}
}
Collections.reverse(records);
List<ChatRuntimeMessage> messages = new ArrayList<>(records.size());
for (ChatMessageRecord record : records) {
@@ -118,11 +176,127 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
command.setContentType(message.getContentType());
command.setContentText(message.getContentText());
command.setContentPayload(message.getContentPayload());
command.setRoundId(message.getRoundId());
command.setRoundNo(message.getRoundNo());
command.setMessageKind(message.getMessageKind());
command.setVariantIndex(message.getVariantIndex());
command.setCreatedBy(defaultNumber(context.getUserId()));
command.setCreated(message.getCreatedAt());
return command;
}
private boolean prepareRoundContext(ChatRuntimeContext context, ChatRuntimeMessage message) {
if (context == null || message == null || context.getSessionId() == null) {
return false;
}
BigInteger regenerateRoundId = resolveNumber(context, ChatRuntimeExtKeys.REGENERATE_ROUND_ID);
if (regenerateRoundId != null) {
ChatRoundRecord round = chatRoundOperateService.requireRegeneratableRound(context.getSessionId(), regenerateRoundId);
context.getExt().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, round.getId());
context.getExt().put(ChatRuntimeExtKeys.CURRENT_ROUND_NO, round.getRoundNo());
context.getExt().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, Math.max(round.getVariantCount() + 1, 1));
ChatRoundUpsertCommand command = new ChatRoundUpsertCommand();
command.setRoundId(round.getId());
command.setSessionId(round.getSessionId());
command.setRoundNo(round.getRoundNo());
command.setUserMessageId(round.getUserMessageId());
command.setSelectedAssistantMessageId(round.getSelectedAssistantMessageId());
command.setSelectedVariantIndex(round.getSelectedVariantIndex());
command.setVariantCount(round.getVariantCount());
command.setStatus(ChatConstants.ROUND_STATUS_ANSWERING);
command.setOperatorId(defaultNumber(context.getUserId()));
chatPersistDispatcher.createOrTouchRound(command);
return true;
}
ChatRoundRecord latestRound = chatRoundQueryService.getLatestRound(context.getSessionId());
if (latestRound != null && latestRound.getId() != null
&& !ChatConstants.ROUND_STATUS_LOCKED.equalsIgnoreCase(latestRound.getStatus())) {
ChatRoundUpsertCommand lockCommand = new ChatRoundUpsertCommand();
lockCommand.setRoundId(latestRound.getId());
lockCommand.setSessionId(latestRound.getSessionId());
lockCommand.setRoundNo(latestRound.getRoundNo());
lockCommand.setUserMessageId(latestRound.getUserMessageId());
lockCommand.setSelectedAssistantMessageId(latestRound.getSelectedAssistantMessageId());
lockCommand.setSelectedVariantIndex(latestRound.getSelectedVariantIndex());
lockCommand.setVariantCount(latestRound.getVariantCount());
lockCommand.setStatus(ChatConstants.ROUND_STATUS_LOCKED);
lockCommand.setOperatorId(defaultNumber(context.getUserId()));
chatPersistDispatcher.createOrTouchRound(lockCommand);
}
BigInteger roundId = BigInteger.valueOf(idGenerator.nextId());
int roundNo = latestRound == null || latestRound.getRoundNo() == null ? 1 : latestRound.getRoundNo() + 1;
if (message.getMessageId() == null) {
message.setMessageId(BigInteger.valueOf(idGenerator.nextId()));
}
context.getExt().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, roundId);
context.getExt().put(ChatRuntimeExtKeys.CURRENT_ROUND_NO, roundNo);
context.getExt().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, 1);
message.setRoundId(roundId);
message.setRoundNo(roundNo);
message.setMessageKind(ChatConstants.MESSAGE_KIND_USER_PROMPT);
message.setVariantIndex(null);
ChatRoundUpsertCommand command = new ChatRoundUpsertCommand();
command.setRoundId(roundId);
command.setSessionId(context.getSessionId());
command.setRoundNo(roundNo);
command.setUserMessageId(message.getMessageId());
command.setSelectedVariantIndex(0);
command.setVariantCount(0);
command.setStatus(ChatConstants.ROUND_STATUS_ANSWERING);
command.setOperatorId(defaultNumber(context.getUserId()));
chatPersistDispatcher.createOrTouchRound(command);
return false;
}
private void applyAssistantRoundMetadata(ChatRuntimeContext context, ChatRuntimeMessage message) {
if (message.getMessageId() == null) {
message.setMessageId(BigInteger.valueOf(idGenerator.nextId()));
}
message.setRoundId(resolveNumber(context, ChatRuntimeExtKeys.CURRENT_ROUND_ID));
message.setRoundNo(resolveInteger(context, ChatRuntimeExtKeys.CURRENT_ROUND_NO));
message.setVariantIndex(resolveInteger(context, ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX));
message.setMessageKind(ChatConstants.MESSAGE_KIND_ASSISTANT_VARIANT);
}
private ChatRoundUpsertCommand buildAssistantCompletedRoundCommand(ChatRuntimeContext context, ChatRuntimeMessage message) {
ChatRoundUpsertCommand command = new ChatRoundUpsertCommand();
command.setRoundId(message.getRoundId());
command.setSessionId(context.getSessionId());
command.setRoundNo(message.getRoundNo());
ChatRoundRecord existing = chatRoundQueryService.getRound(context.getSessionId(), message.getRoundId());
if (existing != null) {
command.setUserMessageId(existing.getUserMessageId());
}
command.setSelectedAssistantMessageId(message.getMessageId());
command.setSelectedVariantIndex(message.getVariantIndex());
command.setVariantCount(message.getVariantIndex());
command.setStatus(ChatConstants.ROUND_STATUS_READY);
command.setOperatorId(defaultNumber(context.getUserId()));
return command;
}
private BigInteger resolveNumber(ChatRuntimeContext context, String key) {
if (context == null || context.getExt() == null || key == null) {
return null;
}
Object value = context.getExt().get(key);
if (value == null) {
return null;
}
return new BigInteger(String.valueOf(value));
}
private Integer resolveInteger(ChatRuntimeContext context, String key) {
if (context == null || context.getExt() == null || key == null) {
return null;
}
Object value = context.getExt().get(key);
if (value == null) {
return null;
}
return Integer.parseInt(String.valueOf(value));
}
private BigInteger defaultNumber(BigInteger value) {
return value == null ? BigInteger.ZERO : value;
}
@@ -133,4 +307,27 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
}
return new BusinessException("聊天记录持久化失败,请稍后重试");
}
private String resolveExtJson(ChatRuntimeContext context) {
if (context == null || context.getExt() == null || context.getExt().isEmpty()) {
return null;
}
if (!context.getExt().containsKey(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS)) {
return null;
}
Object rawExtraKnowledgeIds = context.getExt().get(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS);
if (!(rawExtraKnowledgeIds instanceof List<?> rawList)) {
return null;
}
List<BigInteger> extraKnowledgeIds = new ArrayList<>();
for (Object item : rawList) {
if (item == null) {
continue;
}
extraKnowledgeIds.add(new BigInteger(String.valueOf(item)));
}
ChatSessionExtPayload payload = new ChatSessionExtPayload();
payload.setExtraKnowledgeIds(extraKnowledgeIds);
return chatJsonSupport.toJson(payload);
}
}