package vip.mate.channel; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import vip.mate.channel.model.ChannelSessionEntity; import vip.mate.channel.repository.ChannelSessionMapper; import vip.mate.workspace.conversation.event.ConversationDeletedEvent; import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * 渠道会话存储 *
* 实现 proactive send 机制,缓存各渠道的会话标识映射。 * 每次收到用户消息时自动更新,将 conversationId 映射到平台推送所需的标识。 *
* 内存 + DB 双层持久化:
* - 内存层(ConcurrentHashMap)提供快速查询
* - DB 层(mate_channel_session 表)保证重启后恢复
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChannelSessionStore {
private final ChannelSessionMapper sessionMapper;
/** 内存缓存:conversationId -> ChannelSessionEntity */
private final ConcurrentHashMap {@code deleteConversation} removes the {@code mate_channel_session}
* row inside its DB cascade, but the cache is this class's private state
* and no DB delete can reach it. Without this listener the entry survives
* as a phantom: the next inbound message takes the "update existing" branch
* and calls {@code updateById} against a primary key that no longer exists,
* which affects 0 rows and never re-inserts — so the channel session stays
* missing and proactive push / cron channel resolution silently degrade
* after the next restart.
*
* Runs after the DB cascade commits — see {@link ConversationDeletedEvent}.
*
* 会话被删除后清理内存缓存,避免留下指向已删除行的幽灵条目。
*/
@EventListener
public void onConversationDeleted(ConversationDeletedEvent event) {
if (cache.remove(event.conversationId()) != null) {
log.info("[ChannelSession] Evicted cached session for deleted conversation {}",
event.conversationId());
}
}
/**
* 保存或更新会话标识(收到用户消息时调用)
*
* @param conversationId 会话ID(如 dingtalk:xxx)
* @param channelType 渠道类型
* @param targetId 推送目标标识(sessionWebhook / chat_id / channel_id)
* @param senderId 发送者ID
* @param senderName 发送者名称
* @param channelId 渠道配置ID
*/
public void saveOrUpdate(String conversationId, String channelType, String targetId,
String senderId, String senderName, Long channelId) {
LocalDateTime now = LocalDateTime.now();
ChannelSessionEntity existing = cache.get(conversationId);
if (existing != null) {
// 更新内存和 DB
existing.setTargetId(targetId);
existing.setSenderId(senderId);
existing.setSenderName(senderName);
existing.setChannelId(channelId);
existing.setLastActiveTime(now);
int updated = sessionMapper.updateById(existing);
if (updated > 0) {
log.debug("Updated channel session: conversationId={}, targetId={}", conversationId, targetId);
return;
}
// The cached entity points at a row that no longer exists — the
// conversation was deleted out from under us (deletes are physical;
// no logical-delete column is honoured project-wide). Without this
// self-heal the update silently affects 0 rows on every subsequent
// message and the session is never re-created, so proactive push
// and cron channel resolution break after the next restart.
log.info("Channel session row for {} vanished; re-creating from cache miss", conversationId);
cache.remove(conversationId);
}
// 先查 DB(可能是上次启动后的新记录)
ChannelSessionEntity dbEntity = sessionMapper.selectOne(
new LambdaQueryWrapper Use this rather than the mapper: this class owns the cache, so a
* caller that deletes the row directly leaves a phantom entry behind —
* every later {@code saveOrUpdate} then updates a primary key that no
* longer exists and the session is never re-created.
*
* The conversation-delete cascade does not come through here: it removes
* the row inside its own transaction and lets
* {@link #onConversationDeleted} drop the cache after commit, so the cache
* is never cleared for a delete that later rolls back.
*
* 删除会话(内存 + DB 双层)。
*
* @return number of DB rows removed
*/
public int remove(String conversationId) {
cache.remove(conversationId);
int deleted = sessionMapper.delete(new LambdaQueryWrapper