feat: 落地聊天记录异步持久化基础设施

- 新增 chatlog 模块、AnalyticalDB 公共层与 common-mq Redis Streams 实现

- 建立 Redis 热态、MySQL 热数据、AnalyticalDB 历史查询与同步链路

- 收紧聊天记录幂等、摘要时序与持久化失败语义
This commit is contained in:
2026-04-05 11:35:05 +08:00
parent 1ecc28e498
commit 25e80433a5
105 changed files with 8050 additions and 2 deletions

View File

@@ -0,0 +1,28 @@
package tech.easyflow.chatlog.service;
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;
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
import java.math.BigInteger;
public interface ChatHistoryManageService {
ChatSessionPage queryUserSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query);
ChatSessionPage queryAdminSessions(ChatSessionFilterQuery query);
ChatSessionSummary getUserSession(BigInteger userId, BigInteger sessionId);
ChatSessionSummary getAdminSession(BigInteger sessionId);
ChatHistoryPage queryUserMessages(BigInteger userId, BigInteger sessionId, ChatPageQuery query);
ChatHistoryPage queryAdminMessages(BigInteger sessionId, ChatPageQuery query);
void renameUserSession(BigInteger userId, BigInteger sessionId, String title, BigInteger operatorId);
void deleteUserSession(BigInteger userId, BigInteger sessionId, BigInteger operatorId);
}

View File

@@ -0,0 +1,11 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import java.math.BigInteger;
public interface ChatHistoryQueryService {
ChatHistoryPage queryHistoryMessages(BigInteger sessionId, ChatPageQuery query);
}

View File

@@ -0,0 +1,52 @@
package tech.easyflow.chatlog.service;
import org.springframework.stereotype.Component;
import tech.easyflow.chatlog.domain.entity.ChatPersistDeadLetter;
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
import tech.easyflow.chatlog.mapper.ChatPersistDeadLetterMapper;
import tech.easyflow.chatlog.support.ChatConstants;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.mq.core.MQDeadLetterHandler;
import tech.easyflow.common.mq.core.MQMessage;
import java.util.Date;
@Component
public class ChatPersistDeadLetterService implements MQDeadLetterHandler {
private final ChatPersistDeadLetterMapper deadLetterMapper;
private final ChatJsonSupport chatJsonSupport;
public ChatPersistDeadLetterService(ChatPersistDeadLetterMapper deadLetterMapper,
ChatJsonSupport chatJsonSupport) {
this.deadLetterMapper = deadLetterMapper;
this.chatJsonSupport = chatJsonSupport;
}
@Override
public boolean supports(String topic) {
return ChatConstants.CHAT_PERSIST_TOPIC.equals(topic);
}
@Override
public void handle(MQMessage message, String reason) {
Date now = new Date();
ChatPersistEvent event = chatJsonSupport.fromJson(message.getBody(), ChatPersistEvent.class);
ChatPersistDeadLetter deadLetter = new ChatPersistDeadLetter();
deadLetter.setTopic(message.getTopic());
deadLetter.setStreamKey(message.getStreamKey());
deadLetter.setStreamMessageId(message.getStreamMessageId());
deadLetter.setEventId(event == null ? message.getMessageId() : event.getEventId());
deadLetter.setSessionId(event == null ? null : event.getSessionId());
deadLetter.setPayload(message.getBody());
deadLetter.setRetryCount(message.getRetryCount());
deadLetter.setErrorMessage(reason);
deadLetter.setFirstFailedAt(now);
deadLetter.setLastFailedAt(now);
deadLetter.setStatus("OPEN");
deadLetter.setCreated(now);
deadLetter.setModified(now);
deadLetterMapper.insert(deadLetter);
}
}

View File

@@ -0,0 +1,140 @@
package tech.easyflow.chatlog.service;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.cache.ChatHotStateService;
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
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 java.math.BigInteger;
import java.util.Date;
import java.util.UUID;
@Service
public class ChatPersistDispatcher {
private final ChatHotStateService chatHotStateService;
private final ChatPersistEventProducer eventProducer;
private final ChatJsonSupport chatJsonSupport;
public ChatPersistDispatcher(ChatHotStateService chatHotStateService,
ChatPersistEventProducer eventProducer,
ChatJsonSupport chatJsonSupport) {
this.chatHotStateService = chatHotStateService;
this.eventProducer = eventProducer;
this.chatJsonSupport = chatJsonSupport;
}
public ChatSessionSummary createOrTouchSession(ChatSessionUpsertCommand command) {
ChatSessionSummary summary = chatHotStateService.touchSession(command);
eventProducer.send(buildEvent(
UUID.randomUUID().toString(),
ChatPersistEventType.SESSION_PREPARED,
command.getSessionId(),
command.getUserId(),
command.getAssistantId(),
command.getOperateAt(),
chatJsonSupport.toJson(command)
));
return summary;
}
public void appendUserMessage(ChatAppendMessageCommand command) {
appendMessage(command, ChatPersistEventType.USER_MESSAGE_APPENDED);
}
public void appendAssistantMessage(ChatAppendMessageCommand command) {
appendMessage(command, ChatPersistEventType.ASSISTANT_MESSAGE_APPENDED);
}
public void renameSession(BigInteger sessionId, BigInteger userId, String title, BigInteger operatorId) {
Date operateAt = new Date();
chatHotStateService.renameSession(sessionId, userId, title, operatorId, operateAt);
ChatSessionRenamePayload payload = new ChatSessionRenamePayload();
payload.setSessionId(sessionId);
payload.setUserId(userId);
payload.setTitle(title);
payload.setOperatorId(operatorId);
payload.setOperateAt(operateAt);
eventProducer.send(buildEvent(
UUID.randomUUID().toString(),
ChatPersistEventType.SESSION_RENAMED,
sessionId,
userId,
BigInteger.ZERO,
operateAt,
chatJsonSupport.toJson(payload)
));
}
public void deleteSession(BigInteger sessionId, BigInteger userId, BigInteger operatorId) {
Date operateAt = new Date();
chatHotStateService.deleteSession(sessionId, userId, operatorId, operateAt);
ChatSessionDeletePayload payload = new ChatSessionDeletePayload();
payload.setSessionId(sessionId);
payload.setUserId(userId);
payload.setOperatorId(operatorId);
payload.setOperateAt(operateAt);
eventProducer.send(buildEvent(
UUID.randomUUID().toString(),
ChatPersistEventType.SESSION_DELETED,
sessionId,
userId,
BigInteger.ZERO,
operateAt,
chatJsonSupport.toJson(payload)
));
}
private void appendMessage(ChatAppendMessageCommand command, ChatPersistEventType eventType) {
chatHotStateService.appendMessage(command);
eventProducer.send(buildEvent(
eventId("message", command.getMessageId()),
eventType,
command.getSessionId(),
command.getUserId(),
command.getAssistantId(),
command.getCreated(),
chatJsonSupport.toJson(command)
));
}
private ChatPersistEvent buildEvent(String eventId,
ChatPersistEventType eventType,
BigInteger sessionId,
BigInteger userId,
BigInteger assistantId,
Date occurredAt,
String payload) {
ChatPersistEvent event = new ChatPersistEvent();
event.setEventId(eventId);
event.setEventType(eventType);
event.setSessionId(sessionId);
event.setUserId(userId);
event.setAssistantId(assistantId);
event.setOccurredAt(occurredAt == null ? new Date() : occurredAt);
event.setTraceId(resolveTraceId(eventId));
event.setPayload(payload);
return event;
}
private String resolveTraceId(String fallback) {
String traceId = MDC.get("traceId");
if (traceId == null || traceId.isBlank()) {
return fallback;
}
return traceId;
}
private String eventId(String prefix, BigInteger id) {
return prefix + "-" + (id == null ? UUID.randomUUID() : id);
}
}

View File

@@ -0,0 +1,50 @@
package tech.easyflow.chatlog.service;
import org.springframework.stereotype.Component;
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
import tech.easyflow.chatlog.support.ChatConstants;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.mq.config.MQProperties;
import tech.easyflow.common.mq.core.MQConsumerHandler;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQSubscription;
import java.util.ArrayList;
import java.util.List;
@Component
public class ChatPersistEventConsumer implements MQConsumerHandler {
private final MQProperties mqProperties;
private final ChatJsonSupport chatJsonSupport;
private final ChatPersistMySqlApplyService applyService;
public ChatPersistEventConsumer(MQProperties mqProperties,
ChatJsonSupport chatJsonSupport,
ChatPersistMySqlApplyService applyService) {
this.mqProperties = mqProperties;
this.chatJsonSupport = chatJsonSupport;
this.applyService = applyService;
}
@Override
public MQSubscription subscription() {
MQSubscription subscription = new MQSubscription();
subscription.setTopic(ChatConstants.CHAT_PERSIST_TOPIC);
subscription.setConsumerGroup(ChatConstants.CHAT_PERSIST_GROUP);
subscription.setShardCount(Math.max(mqProperties.getRedis().getChatPersistShardCount(), 1));
return subscription;
}
@Override
public void handle(List<MQMessage> messages) {
List<ChatPersistEvent> events = new ArrayList<>(messages.size());
for (MQMessage message : messages) {
ChatPersistEvent event = chatJsonSupport.fromJson(message.getBody(), ChatPersistEvent.class);
if (event != null) {
events.add(event);
}
}
applyService.apply(events);
}
}

View File

@@ -0,0 +1,33 @@
package tech.easyflow.chatlog.service;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
import tech.easyflow.chatlog.support.ChatConstants;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import tech.easyflow.common.mq.core.MQMessage;
import tech.easyflow.common.mq.core.MQProducer;
@Service
public class ChatPersistEventProducer {
private final MQProducer mqProducer;
private final ChatJsonSupport chatJsonSupport;
public ChatPersistEventProducer(MQProducer mqProducer, ChatJsonSupport chatJsonSupport) {
this.mqProducer = mqProducer;
this.chatJsonSupport = chatJsonSupport;
}
public void send(ChatPersistEvent event) {
MQMessage message = new MQMessage();
message.setMessageId(event.getEventId());
message.setTopic(ChatConstants.CHAT_PERSIST_TOPIC);
message.setKey(event.getSessionId() == null ? event.getEventId() : event.getSessionId().toString());
message.setBody(chatJsonSupport.toJson(event));
message.setCreatedAt(event.getOccurredAt());
if (event.getTraceId() != null && !event.getTraceId().isBlank()) {
message.getHeaders().put("traceId", event.getTraceId());
}
mqProducer.send(message);
}
}

View File

@@ -0,0 +1,153 @@
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.ChatSessionSummaryCommand;
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
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.repository.mysql.MySqlChatLogRepository;
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager;
import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
import tech.easyflow.chatlog.support.ChatJsonSupport;
import java.math.BigInteger;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class ChatPersistMySqlApplyService {
private final MySqlChatSessionRepository sessionRepository;
private final MySqlChatLogRepository logRepository;
private final MySqlChatLogTableManager tableManager;
private final ChatJsonSupport chatJsonSupport;
public ChatPersistMySqlApplyService(MySqlChatSessionRepository sessionRepository,
MySqlChatLogRepository logRepository,
MySqlChatLogTableManager tableManager,
ChatJsonSupport chatJsonSupport) {
this.sessionRepository = sessionRepository;
this.logRepository = logRepository;
this.tableManager = tableManager;
this.chatJsonSupport = chatJsonSupport;
}
@Transactional(rollbackFor = Exception.class)
public void apply(List<ChatPersistEvent> events) {
if (events == null || events.isEmpty()) {
return;
}
Map<BigInteger, ChatSessionUpsertCommand> sessionUpserts = new LinkedHashMap<>();
List<ChatAppendMessageCommand> appendCommands = new ArrayList<>();
Map<BigInteger, ChatSessionSummaryCommand> summaryCommands = new LinkedHashMap<>();
List<ChatSessionRenamePayload> renamePayloads = new ArrayList<>();
List<ChatSessionDeletePayload> deletePayloads = new ArrayList<>();
Set<YearMonth> months = new LinkedHashSet<>();
for (ChatPersistEvent event : events) {
if (event == null || event.getEventType() == null) {
continue;
}
switch (event.getEventType()) {
case SESSION_PREPARED -> {
ChatSessionUpsertCommand command = chatJsonSupport.fromJson(event.getPayload(), ChatSessionUpsertCommand.class);
if (command != null && command.getSessionId() != null) {
sessionUpserts.put(command.getSessionId(), 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) {
continue;
}
appendCommands.add(command);
months.add(resolveMonth(command.getCreated()));
accumulateSummary(summaryCommands, command);
}
case SESSION_RENAMED -> {
ChatSessionRenamePayload payload = chatJsonSupport.fromJson(event.getPayload(), ChatSessionRenamePayload.class);
if (payload != null && payload.getSessionId() != null) {
renamePayloads.add(payload);
}
}
case SESSION_DELETED -> {
ChatSessionDeletePayload payload = chatJsonSupport.fromJson(event.getPayload(), ChatSessionDeletePayload.class);
if (payload != null && payload.getSessionId() != null) {
deletePayloads.add(payload);
}
}
default -> {
}
}
}
if (!sessionUpserts.isEmpty()) {
sessionRepository.createOrTouchBatch(new ArrayList<>(sessionUpserts.values()));
}
if (!months.isEmpty()) {
for (YearMonth month : months) {
tableManager.ensureMonthTable(month);
}
}
List<ChatAppendMessageCommand> insertedCommands = List.of();
if (!appendCommands.isEmpty()) {
insertedCommands = logRepository.appendMessages(appendCommands);
}
if (!insertedCommands.isEmpty()) {
summaryCommands.clear();
for (ChatAppendMessageCommand insertedCommand : insertedCommands) {
accumulateSummary(summaryCommands, insertedCommand);
}
sessionRepository.updateSummaries(new ArrayList<>(summaryCommands.values()));
}
if (!renamePayloads.isEmpty()) {
sessionRepository.renameSessions(renamePayloads);
}
if (!deletePayloads.isEmpty()) {
sessionRepository.deleteSessions(deletePayloads);
}
}
private void accumulateSummary(Map<BigInteger, ChatSessionSummaryCommand> summaryCommands,
ChatAppendMessageCommand command) {
ChatSessionSummaryCommand summary = summaryCommands.computeIfAbsent(command.getSessionId(), key -> {
ChatSessionSummaryCommand created = new ChatSessionSummaryCommand();
created.setSessionId(command.getSessionId());
created.setUserId(command.getUserId());
created.setMessageIncrement(0);
return created;
});
summary.setMessageIncrement(summary.getMessageIncrement() + 1);
if (summary.getLastMessageAt() == null || !command.getCreated().before(summary.getLastMessageAt())) {
summary.setLastSenderId(command.getSenderId());
summary.setLastSenderName(command.getSenderName());
summary.setLastMessagePreview(trimPreview(command.getContentText()));
summary.setLastMessageAt(command.getCreated());
summary.setOperatorId(command.getCreatedBy());
}
}
private YearMonth resolveMonth(Date createdAt) {
Date created = createdAt == null ? new Date() : createdAt;
return YearMonth.from(created.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
}
private String trimPreview(String text) {
if (text == null) {
return "";
}
return text.length() <= 200 ? text : text.substring(0, 200);
}
}

View File

@@ -0,0 +1,15 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import java.math.BigInteger;
public interface ChatSessionCommandService {
ChatSessionSummary createOrTouchSession(ChatSessionUpsertCommand command);
void renameSession(BigInteger sessionId, BigInteger userId, String title, BigInteger operatorId);
void deleteSession(BigInteger sessionId, BigInteger userId, BigInteger operatorId);
}

View File

@@ -0,0 +1,22 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import java.math.BigInteger;
import java.util.List;
public interface ChatSessionQueryService {
List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query);
long countSessions(BigInteger userId, BigInteger assistantId);
ChatSessionPage pageSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query);
ChatSessionSummary getSessionSummary(BigInteger sessionId);
List<ChatMessageRecord> getRecentTail(BigInteger sessionId, int limit);
}

View File

@@ -0,0 +1,9 @@
package tech.easyflow.chatlog.service;
import com.mybatisflex.core.service.IService;
import tech.easyflow.chatlog.domain.entity.ChatSyncCheckpoint;
public interface ChatSyncCheckpointService extends IService<ChatSyncCheckpoint> {
ChatSyncCheckpoint getOrCreate(String syncCode, String shardKey);
}

View File

@@ -0,0 +1,16 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.dto.ChatSyncResult;
public interface ChatSyncService {
ChatSyncResult syncSessions();
ChatSyncResult syncLogs();
ChatSyncResult repairLogs();
void maintainMysqlTables();
void startupCheck();
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.chatlog.service;
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
import java.math.BigInteger;
public interface PublicChatSessionRestoreService {
PublicChatSessionRestoreResult restoreSession(BigInteger userId, BigInteger assistantId, BigInteger sessionId, Integer limit);
}

View File

@@ -0,0 +1,95 @@
package tech.easyflow.chatlog.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
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;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository;
import tech.easyflow.chatlog.service.ChatHistoryManageService;
import tech.easyflow.chatlog.service.ChatHistoryQueryService;
import tech.easyflow.chatlog.service.ChatSessionCommandService;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
@Service
public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
private final ChatSessionQueryService chatSessionQueryService;
private final ChatSessionCommandService chatSessionCommandService;
private final ChatHistoryQueryService chatHistoryQueryService;
private final ChatAnalyticalDBRepository chatAnalyticalDBRepository;
public ChatHistoryManageServiceImpl(ChatSessionQueryService chatSessionQueryService,
ChatSessionCommandService chatSessionCommandService,
ChatHistoryQueryService chatHistoryQueryService,
ChatAnalyticalDBRepository chatAnalyticalDBRepository) {
this.chatSessionQueryService = chatSessionQueryService;
this.chatSessionCommandService = chatSessionCommandService;
this.chatHistoryQueryService = chatHistoryQueryService;
this.chatAnalyticalDBRepository = chatAnalyticalDBRepository;
}
@Override
public ChatSessionPage queryUserSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
return chatSessionQueryService.pageSessions(userId, assistantId, query);
}
@Override
public ChatSessionPage queryAdminSessions(ChatSessionFilterQuery query) {
return chatAnalyticalDBRepository.pageSessions(query);
}
@Override
public ChatSessionSummary getUserSession(BigInteger userId, BigInteger sessionId) {
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
if (summary == null || summary.getIsDeleted() != null && summary.getIsDeleted() == 1) {
throw new BusinessException("会话不存在");
}
if (!summary.getUserId().equals(userId)) {
throw new BusinessException("无权访问该会话");
}
return summary;
}
@Override
public ChatSessionSummary getAdminSession(BigInteger sessionId) {
ChatSessionSummary summary = chatAnalyticalDBRepository.getSession(sessionId);
if (summary == null || summary.getIsDeleted() != null && summary.getIsDeleted() == 1) {
throw new BusinessException("会话不存在");
}
return summary;
}
@Override
public ChatHistoryPage queryUserMessages(BigInteger userId, BigInteger sessionId, ChatPageQuery query) {
getUserSession(userId, sessionId);
return chatHistoryQueryService.queryHistoryMessages(sessionId, query);
}
@Override
public ChatHistoryPage queryAdminMessages(BigInteger sessionId, ChatPageQuery query) {
getAdminSession(sessionId);
return chatHistoryQueryService.queryHistoryMessages(sessionId, query);
}
@Override
public void renameUserSession(BigInteger userId, BigInteger sessionId, String title, BigInteger operatorId) {
if (!StringUtils.hasText(title)) {
throw new BusinessException("标题不能为空");
}
getUserSession(userId, sessionId);
chatSessionCommandService.renameSession(sessionId, userId, title.trim(), operatorId);
}
@Override
public void deleteUserSession(BigInteger userId, BigInteger sessionId, BigInteger operatorId) {
getUserSession(userId, sessionId);
chatSessionCommandService.deleteSession(sessionId, userId, operatorId);
}
}

View File

@@ -0,0 +1,24 @@
package tech.easyflow.chatlog.service.impl;
import org.springframework.stereotype.Service;
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 java.math.BigInteger;
@Service
public class ChatHistoryQueryServiceImpl implements ChatHistoryQueryService {
private final ChatAnalyticalDBRepository chatAnalyticalDBRepository;
public ChatHistoryQueryServiceImpl(ChatAnalyticalDBRepository chatAnalyticalDBRepository) {
this.chatAnalyticalDBRepository = chatAnalyticalDBRepository;
}
@Override
public ChatHistoryPage queryHistoryMessages(BigInteger sessionId, ChatPageQuery query) {
return chatAnalyticalDBRepository.queryHistory(sessionId, query);
}
}

View File

@@ -0,0 +1,34 @@
package tech.easyflow.chatlog.service.impl;
import org.springframework.stereotype.Service;
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 java.math.BigInteger;
@Service
public class ChatSessionCommandServiceImpl implements ChatSessionCommandService {
private final ChatPersistDispatcher chatPersistDispatcher;
public ChatSessionCommandServiceImpl(ChatPersistDispatcher chatPersistDispatcher) {
this.chatPersistDispatcher = chatPersistDispatcher;
}
@Override
public ChatSessionSummary createOrTouchSession(ChatSessionUpsertCommand command) {
return chatPersistDispatcher.createOrTouchSession(command);
}
@Override
public void renameSession(BigInteger sessionId, BigInteger userId, String title, BigInteger operatorId) {
chatPersistDispatcher.renameSession(sessionId, userId, title, operatorId);
}
@Override
public void deleteSession(BigInteger sessionId, BigInteger userId, BigInteger operatorId) {
chatPersistDispatcher.deleteSession(sessionId, userId, operatorId);
}
}

View File

@@ -0,0 +1,108 @@
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.ChatSessionPage;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
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 java.math.BigInteger;
import java.util.List;
@Service
public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
private final MySqlChatSessionRepository sessionRepository;
private final MySqlChatLogRepository logRepository;
private final MySqlChatLogTableManager tableManager;
private final ChatHotStateService chatHotStateService;
public ChatSessionQueryServiceImpl(MySqlChatSessionRepository sessionRepository,
MySqlChatLogRepository logRepository,
MySqlChatLogTableManager tableManager,
ChatHotStateService chatHotStateService) {
this.sessionRepository = sessionRepository;
this.logRepository = logRepository;
this.tableManager = tableManager;
this.chatHotStateService = chatHotStateService;
}
@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;
}
@Override
public long countSessions(BigInteger userId, BigInteger assistantId) {
return sessionRepository.countSessions(userId, assistantId);
}
@Override
public ChatSessionPage pageSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
ChatSessionPage page = new ChatSessionPage();
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;
}
@Override
public ChatSessionSummary getSessionSummary(BigInteger sessionId) {
ChatSessionSummary cached = chatHotStateService.getSessionSummary(sessionId);
if (cached != null) {
return cached;
}
ChatSessionSummary summary = sessionRepository.findBySessionId(sessionId);
if (summary != null) {
chatHotStateService.cacheSessionSummary(summary);
}
return summary;
}
@Override
public List<ChatMessageRecord> getRecentTail(BigInteger sessionId, int limit) {
List<ChatMessageRecord> cached = chatHotStateService.getSessionTail(sessionId);
if (cached != null) {
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;
}
}

View File

@@ -0,0 +1,33 @@
package tech.easyflow.chatlog.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.domain.entity.ChatSyncCheckpoint;
import tech.easyflow.chatlog.mapper.ChatSyncCheckpointMapper;
import tech.easyflow.chatlog.service.ChatSyncCheckpointService;
import java.util.Date;
@Service
public class ChatSyncCheckpointServiceImpl extends ServiceImpl<ChatSyncCheckpointMapper, ChatSyncCheckpoint>
implements ChatSyncCheckpointService {
@Override
public ChatSyncCheckpoint getOrCreate(String syncCode, String shardKey) {
QueryWrapper wrapper = QueryWrapper.create()
.eq(ChatSyncCheckpoint::getSyncCode, syncCode)
.eq(ChatSyncCheckpoint::getShardKey, shardKey);
ChatSyncCheckpoint checkpoint = getMapper().selectOneByQuery(wrapper);
if (checkpoint != null) {
return checkpoint;
}
ChatSyncCheckpoint created = new ChatSyncCheckpoint();
created.setSyncCode(syncCode);
created.setShardKey(shardKey);
created.setStatus("READY");
created.setModified(new Date());
save(created);
return created;
}
}

View File

@@ -0,0 +1,190 @@
package tech.easyflow.chatlog.service.impl;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.config.ChatSyncProperties;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.dto.ChatSyncResult;
import tech.easyflow.chatlog.domain.entity.ChatSyncCheckpoint;
import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository;
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.ChatSyncCheckpointService;
import tech.easyflow.chatlog.service.ChatSyncService;
import tech.easyflow.chatlog.support.ChatConstants;
import java.math.BigInteger;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
@Service
public class ChatSyncServiceImpl implements ChatSyncService {
private final MySqlChatSessionRepository sessionRepository;
private final MySqlChatLogRepository logRepository;
private final MySqlChatLogTableManager tableManager;
private final ChatAnalyticalDBRepository analyticalDBRepository;
private final ChatSyncCheckpointService checkpointService;
private final ChatSyncProperties syncProperties;
public ChatSyncServiceImpl(MySqlChatSessionRepository sessionRepository,
MySqlChatLogRepository logRepository,
MySqlChatLogTableManager tableManager,
ChatAnalyticalDBRepository analyticalDBRepository,
ChatSyncCheckpointService checkpointService,
ChatSyncProperties syncProperties) {
this.sessionRepository = sessionRepository;
this.logRepository = logRepository;
this.tableManager = tableManager;
this.analyticalDBRepository = analyticalDBRepository;
this.checkpointService = checkpointService;
this.syncProperties = syncProperties;
}
@Override
public ChatSyncResult syncSessions() {
ChatSyncResult result = new ChatSyncResult();
result.setSyncCode(ChatConstants.CHECKPOINT_SYNC_CODE_SESSION);
if (!analyticalDBRepository.enabled()) {
return result;
}
int totalRows = 0;
ChatSyncCheckpoint checkpoint = checkpointService.getOrCreate(ChatConstants.CHECKPOINT_SYNC_CODE_SESSION, "default");
List<ChatSessionSummary> rows = sessionRepository.loadModifiedAfter(
checkpoint.getCursorTime(),
checkpoint.getCursorId(),
syncProperties.getBatchSize()
);
if (!rows.isEmpty()) {
analyticalDBRepository.upsertSessions(rows);
ChatSessionSummary last = rows.get(rows.size() - 1);
checkpoint.setCursorTime(last.getModified());
checkpoint.setCursorId(last.getId());
checkpoint.setCursorTable(ChatConstants.SESSION_TABLE);
checkpoint.setLastBatchSize(rows.size());
checkpoint.setLastSuccessTime(new Date());
checkpoint.setStatus("SUCCESS");
checkpoint.setModified(new Date());
checkpointService.updateById(checkpoint);
totalRows += rows.size();
}
result.setSyncedRows(totalRows);
return result;
}
@Override
public ChatSyncResult syncLogs() {
ChatSyncResult result = new ChatSyncResult();
result.setSyncCode(ChatConstants.CHECKPOINT_SYNC_CODE_LOG);
if (!analyticalDBRepository.enabled()) {
return result;
}
ChatSyncCheckpoint checkpoint = checkpointService.getOrCreate(ChatConstants.CHECKPOINT_SYNC_CODE_LOG, "default");
List<YearMonth> months = tableManager.listRecentExistingMonths(syncProperties.getRetentionMonths());
Set<LocalDate> touchedDates = new LinkedHashSet<>();
int totalRows = 0;
for (YearMonth month : months) {
String table = "chat_log_" + month.toString().replace("-", "");
if (checkpoint.getCursorTable() != null && checkpoint.getCursorTable().compareTo(table) > 0) {
continue;
}
List<ChatMessageRecord> rows = logRepository.loadIncremental(
table,
table.equals(checkpoint.getCursorTable()) ? checkpoint.getCursorTime() : null,
table.equals(checkpoint.getCursorTable()) ? checkpoint.getCursorId() : BigInteger.ZERO,
syncProperties.getBatchSize()
);
if (rows.isEmpty()) {
continue;
}
analyticalDBRepository.appendLogs(rows);
ChatMessageRecord last = rows.get(rows.size() - 1);
checkpoint.setCursorTable(table);
checkpoint.setCursorTime(last.getCreated());
checkpoint.setCursorId(last.getId());
checkpoint.setLastBatchSize(rows.size());
checkpoint.setLastSuccessTime(new Date());
checkpoint.setStatus("SUCCESS");
checkpoint.setModified(new Date());
checkpointService.updateById(checkpoint);
totalRows += rows.size();
rows.stream()
.map(item -> item.getCreated().toInstant().atZone(ZoneId.systemDefault()).toLocalDate())
.forEach(touchedDates::add);
}
analyticalDBRepository.refreshDws(touchedDates);
result.setSyncedRows(totalRows);
result.setTouchedDates(new ArrayList<>(touchedDates.stream().map(LocalDate::toString).toList()));
return result;
}
@Override
public ChatSyncResult repairLogs() {
ChatSyncResult result = new ChatSyncResult();
result.setSyncCode("chat_log_repair");
if (!analyticalDBRepository.enabled()) {
return result;
}
Date startTime = Date.from(LocalDate.now()
.minusDays(syncProperties.getRepairLookbackDays())
.atStartOfDay(ZoneId.systemDefault())
.toInstant());
List<ChatMessageRecord> rows = logRepository.loadRepairRows(
tableManager.listRecentExistingMonths(syncProperties.getRetentionMonths()),
startTime
);
analyticalDBRepository.appendLogs(rows);
Set<LocalDate> dates = new LinkedHashSet<>();
rows.stream().map(item -> item.getCreated().toInstant().atZone(ZoneId.systemDefault()).toLocalDate())
.forEach(dates::add);
analyticalDBRepository.refreshDws(dates);
result.setSyncedRows(rows.size());
result.setTouchedDates(new ArrayList<>(dates.stream().map(LocalDate::toString).toList()));
return result;
}
@Override
public void maintainMysqlTables() {
tableManager.ensureCurrentAndNextMonth();
clearExpiredSessions();
if (!analyticalDBRepository.enabled()) {
return;
}
ChatSyncCheckpoint checkpoint = checkpointService.getOrCreate(ChatConstants.CHECKPOINT_SYNC_CODE_LOG, "default");
YearMonth threshold = YearMonth.now().minusMonths(syncProperties.getRetentionMonths());
for (int i = syncProperties.getRetentionMonths() + 1; i <= 24; i++) {
YearMonth month = YearMonth.now().minusMonths(i);
if (checkpoint.getCursorTable() != null && checkpoint.getCursorTable().compareTo("chat_log_" + month.toString().replace("-", "")) <= 0) {
continue;
}
if (month.isBefore(threshold) && tableManager.tableExists("chat_log_" + month.toString().replace("-", ""))) {
tableManager.dropMonthTable(month);
}
}
}
@Override
public void startupCheck() {
tableManager.ensureCurrentAndNextMonth();
if (analyticalDBRepository.enabled()) {
analyticalDBRepository.selfCheck();
}
}
private void clearExpiredSessions() {
Date expireBefore = Date.from(LocalDate.now()
.minusMonths(syncProperties.getRetentionMonths())
.atStartOfDay(ZoneId.systemDefault())
.toInstant());
while (sessionRepository.deleteExpiredSessions(expireBefore, syncProperties.getBatchSize()) > 0) {
// loop until all expired session hot data is purged
}
}
}

View File

@@ -0,0 +1,130 @@
package tech.easyflow.chatlog.service.impl;
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.ChatSessionUpsertCommand;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.service.ChatPersistDispatcher;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.core.runtime.ChatRuntimeListener;
import tech.easyflow.core.runtime.ChatRuntimeMessage;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Component
@Order(100)
public class ChatlogRuntimeListener implements ChatRuntimeListener {
private final SnowFlakeIDKeyGenerator idGenerator = new SnowFlakeIDKeyGenerator();
private final ChatPersistDispatcher chatPersistDispatcher;
private final ChatSessionQueryService chatSessionQueryService;
public ChatlogRuntimeListener(ChatPersistDispatcher chatPersistDispatcher,
ChatSessionQueryService chatSessionQueryService) {
this.chatPersistDispatcher = chatPersistDispatcher;
this.chatSessionQueryService = chatSessionQueryService;
}
@Override
public void onSessionPrepared(ChatRuntimeContext context) {
try {
ChatSessionUpsertCommand command = new ChatSessionUpsertCommand();
command.setSessionId(context.getSessionId());
command.setTenantId(defaultNumber(context.getTenantId()));
command.setDeptId(defaultNumber(context.getDeptId()));
command.setUserId(defaultNumber(context.getUserId()));
command.setUserAccount(context.getUserAccount());
command.setAssistantId(defaultNumber(context.getAssistantId()));
command.setAssistantCode(context.getAssistantCode());
command.setAssistantName(context.getAssistantName());
command.setTitle(context.getSessionTitle());
command.setOperatorId(defaultNumber(context.getUserId()));
chatPersistDispatcher.createOrTouchSession(command);
} catch (RuntimeException ex) {
throw persistFailed(ex);
}
}
@Override
public void onUserMessage(ChatRuntimeContext context, ChatRuntimeMessage message) {
try {
chatPersistDispatcher.appendUserMessage(toAppendCommand(context, message));
} catch (RuntimeException ex) {
throw persistFailed(ex);
}
}
@Override
public void onAssistantCompleted(ChatRuntimeContext context, ChatRuntimeMessage message) {
try {
chatPersistDispatcher.appendAssistantMessage(toAppendCommand(context, message));
} catch (RuntimeException ex) {
throw persistFailed(ex);
}
}
@Override
public List<ChatRuntimeMessage> loadMessages(ChatRuntimeContext context, int limit) {
if (context == null || context.getSessionId() == null || limit <= 0) {
return Collections.emptyList();
}
List<ChatMessageRecord> records = new ArrayList<>(chatSessionQueryService.getRecentTail(context.getSessionId(), limit));
Collections.reverse(records);
List<ChatRuntimeMessage> messages = new ArrayList<>(records.size());
for (ChatMessageRecord record : records) {
if (record.getContentText() == null || record.getContentText().isBlank()) {
continue;
}
ChatRuntimeMessage message = new ChatRuntimeMessage();
message.setMessageId(record.getId());
message.setRole(record.getSenderRole());
message.setContentType(record.getContentType());
message.setContentText(record.getContentText());
message.setContentPayload(record.getContentPayload());
message.setCreatedAt(record.getCreated());
message.setSenderId(record.getSenderId());
message.setSenderName(record.getSenderName());
messages.add(message);
}
return messages;
}
private ChatAppendMessageCommand toAppendCommand(ChatRuntimeContext context, ChatRuntimeMessage message) {
ChatAppendMessageCommand command = new ChatAppendMessageCommand();
command.setMessageId(message.getMessageId() == null ? BigInteger.valueOf(idGenerator.nextId()) : message.getMessageId());
command.setTenantId(defaultNumber(context.getTenantId()));
command.setDeptId(defaultNumber(context.getDeptId()));
command.setSessionId(context.getSessionId());
command.setUserId(defaultNumber(context.getUserId()));
command.setAssistantId(defaultNumber(context.getAssistantId()));
command.setSenderId(defaultNumber(message.getSenderId()));
command.setSenderName(message.getSenderName());
command.setSenderRole(message.getRole());
command.setContentType(message.getContentType());
command.setContentText(message.getContentText());
command.setContentPayload(message.getContentPayload());
command.setCreatedBy(defaultNumber(context.getUserId()));
command.setCreated(message.getCreatedAt());
return command;
}
private BigInteger defaultNumber(BigInteger value) {
return value == null ? BigInteger.ZERO : value;
}
private BusinessException persistFailed(RuntimeException ex) {
if (ex instanceof BusinessException businessException) {
return businessException;
}
return new BusinessException("聊天记录持久化失败,请稍后重试");
}
}

View File

@@ -0,0 +1,64 @@
package tech.easyflow.chatlog.service.impl;
import org.springframework.stereotype.Service;
import tech.easyflow.chatlog.config.ChatCacheProperties;
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.chatlog.service.PublicChatSessionRestoreService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@Service
public class PublicChatSessionRestoreServiceImpl implements PublicChatSessionRestoreService {
private final ChatSessionQueryService chatSessionQueryService;
private final ChatCacheProperties chatCacheProperties;
public PublicChatSessionRestoreServiceImpl(ChatSessionQueryService chatSessionQueryService,
ChatCacheProperties chatCacheProperties) {
this.chatSessionQueryService = chatSessionQueryService;
this.chatCacheProperties = chatCacheProperties;
}
@Override
public PublicChatSessionRestoreResult restoreSession(BigInteger userId, BigInteger assistantId, BigInteger sessionId, Integer limit) {
PublicChatSessionRestoreResult result = new PublicChatSessionRestoreResult();
result.setConversationId(sessionId == null ? null : sessionId.toString());
if (userId == null || assistantId == null || sessionId == null) {
return result;
}
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
return result;
}
if (!Objects.equals(summary.getUserId(), userId)) {
return result;
}
if (!Objects.equals(summary.getAssistantId(), assistantId)) {
return result;
}
List<ChatMessageRecord> tailMessages = new ArrayList<>(chatSessionQueryService.getRecentTail(sessionId, resolveLimit(limit)));
Collections.reverse(tailMessages);
result.setSessionExists(true);
result.setSession(summary);
result.setMessages(tailMessages);
return result;
}
private int resolveLimit(Integer limit) {
int defaultLimit = Math.max(chatCacheProperties.getTailSize(), 1);
if (limit == null || limit <= 0) {
return defaultLimit;
}
return Math.min(limit, defaultLimit);
}
}