package com.vincent.rsf.server.ai.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.vincent.rsf.framework.common.Cools; import com.vincent.rsf.framework.exception.CoolException; import com.vincent.rsf.server.ai.config.AiDefaults; import com.vincent.rsf.server.ai.dto.AiChatMemoryDto; import com.vincent.rsf.server.ai.dto.AiChatMessageDto; import com.vincent.rsf.server.ai.dto.AiChatSessionPinRequest; import com.vincent.rsf.server.ai.dto.AiChatSessionRenameRequest; import com.vincent.rsf.server.ai.dto.AiChatSessionDto; import com.vincent.rsf.server.ai.entity.AiChatMessage; import com.vincent.rsf.server.ai.entity.AiChatSession; import com.vincent.rsf.server.ai.mapper.AiChatMessageMapper; import com.vincent.rsf.server.ai.mapper.AiChatSessionMapper; import com.vincent.rsf.server.ai.service.AiChatMemoryService; import com.vincent.rsf.server.system.enums.StatusType; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.ConcurrentHashMap; @Service @Slf4j @RequiredArgsConstructor public class AiChatMemoryServiceImpl implements AiChatMemoryService { private final AiChatSessionMapper aiChatSessionMapper; private final AiChatMessageMapper aiChatMessageMapper; private final AiRedisSupport aiRedisSupport; @Qualifier("aiMemoryTaskExecutor") private final Executor aiMemoryTaskExecutor; /** * 用两个本地集合把“同一个会话的摘要刷新”合并成串行任务,避免连续消息把重复任务塞满线程池。 */ private final Set refreshingSessionIds = ConcurrentHashMap.newKeySet(); private final Set pendingRefreshSessionIds = ConcurrentHashMap.newKeySet(); /** * 读取会话记忆快照。 * 返回结果同时包含完整落库历史、短期记忆窗口以及摘要/事实记忆, * 便于调用方按不同用途选择数据粒度。 */ @Override public AiChatMemoryDto getMemory(Long userId, Long tenantId, String promptCode, Long sessionId) { ensureIdentity(userId, tenantId); String resolvedPromptCode = requirePromptCode(promptCode); // 会话记忆属于典型“读多写少”数据,先走短 TTL 缓存能明显减轻抽屉初始化和切会话压力。 AiChatMemoryDto cached = aiRedisSupport.getMemory(tenantId, userId, resolvedPromptCode, sessionId); if (cached != null) { return cached; } AiChatSession session = sessionId == null ? findLatestSession(userId, tenantId, resolvedPromptCode) : getSession(sessionId, userId, tenantId, resolvedPromptCode); AiChatMemoryDto memory; if (session == null) { memory = AiChatMemoryDto.builder() .sessionId(null) .memorySummary(null) .memoryFacts(null) .recentMessageCount(0) .persistedMessages(List.of()) .shortMemoryMessages(List.of()) .build(); aiRedisSupport.cacheMemory(tenantId, userId, resolvedPromptCode, sessionId, memory); return memory; } List persistedMessages = listMessages(session.getId()); List shortMemoryMessages = tailMessagesByRounds(persistedMessages, AiDefaults.MEMORY_RECENT_ROUNDS); memory = AiChatMemoryDto.builder() .sessionId(session.getId()) .memorySummary(session.getMemorySummary()) .memoryFacts(session.getMemoryFacts()) .recentMessageCount(shortMemoryMessages.size()) .persistedMessages(persistedMessages) .shortMemoryMessages(shortMemoryMessages) .build(); aiRedisSupport.cacheMemory(tenantId, userId, resolvedPromptCode, session.getId(), memory); if (sessionId == null || !session.getId().equals(sessionId)) { aiRedisSupport.cacheMemory(tenantId, userId, resolvedPromptCode, null, memory); } return memory; } /** * 查询当前用户在某个 Prompt 下的会话列表。 * 列表只返回用于侧边栏展示的摘要信息,不返回完整对话内容。 */ @Override public List listSessions(Long userId, Long tenantId, String promptCode, String keyword) { ensureIdentity(userId, tenantId); String resolvedPromptCode = requirePromptCode(promptCode); List cached = aiRedisSupport.getSessionList(tenantId, userId, resolvedPromptCode, keyword); if (cached != null) { return cached; } List sessions = aiChatSessionMapper.selectList(new LambdaQueryWrapper() .eq(AiChatSession::getUserId, userId) .eq(AiChatSession::getTenantId, tenantId) .eq(AiChatSession::getPromptCode, resolvedPromptCode) .eq(AiChatSession::getDeleted, 0) .eq(AiChatSession::getStatus, StatusType.ENABLE.val) .like(StringUtils.hasText(keyword), AiChatSession::getTitle, keyword == null ? null : keyword.trim()) .orderByDesc(AiChatSession::getPinned) .orderByDesc(AiChatSession::getLastMessageTime) .orderByDesc(AiChatSession::getId)); if (Cools.isEmpty(sessions)) { aiRedisSupport.cacheSessionList(tenantId, userId, resolvedPromptCode, keyword, List.of()); return List.of(); } List result = new ArrayList<>(); for (AiChatSession session : sessions) { result.add(buildSessionDto(session)); } aiRedisSupport.cacheSessionList(tenantId, userId, resolvedPromptCode, keyword, result); return result; } /** * 解析本轮请求应该落到哪个会话。 * 如果前端带了 sessionId 则做归属校验并复用;否则自动创建新会话。 */ @Override public AiChatSession resolveSession(Long userId, Long tenantId, String promptCode, Long sessionId, String titleSeed) { ensureIdentity(userId, tenantId); String resolvedPromptCode = requirePromptCode(promptCode); if (sessionId != null) { return getSession(sessionId, userId, tenantId, resolvedPromptCode); } Date now = new Date(); AiChatSession session = new AiChatSession() .setTitle(buildSessionTitle(titleSeed)) .setPromptCode(resolvedPromptCode) .setUserId(userId) .setTenantId(tenantId) .setLastMessageTime(now) .setPinned(0) .setStatus(StatusType.ENABLE.val) .setDeleted(0) .setCreateBy(userId) .setCreateTime(now) .setUpdateBy(userId) .setUpdateTime(now); aiChatSessionMapper.insert(session); evictConversationCaches(tenantId, userId); return session; } /** * 落库保存一整轮对话。 * 这里会顺序写入本轮用户消息和模型回复,并在最后刷新会话标题和活跃时间。 * 记忆画像改为后台异步刷新,避免把摘要重算耗时压在用户本轮响应尾部。 */ @Override public void saveRound(AiChatSession session, Long userId, Long tenantId, List memoryMessages, String assistantContent) { if (session == null || session.getId() == null) { throw new CoolException("AI 会话不存在"); } ensureIdentity(userId, tenantId); List normalizedMessages = normalizeMessages(memoryMessages); if (normalizedMessages.isEmpty()) { throw new CoolException("本轮没有可保存的对话消息"); } int nextSeqNo = findNextSeqNo(session.getId()); Date now = new Date(); for (AiChatMessageDto message : normalizedMessages) { aiChatMessageMapper.insert(buildMessageEntity(session.getId(), nextSeqNo++, message.getRole(), message.getContent(), userId, tenantId, now)); } if (StringUtils.hasText(assistantContent)) { aiChatMessageMapper.insert(buildMessageEntity(session.getId(), nextSeqNo, "assistant", assistantContent, userId, tenantId, now)); } AiChatSession update = new AiChatSession() .setId(session.getId()) .setTitle(resolveUpdatedTitle(session.getTitle(), normalizedMessages)) .setLastMessageTime(now) .setUpdateBy(userId) .setUpdateTime(now); aiChatSessionMapper.updateById(update); evictConversationCaches(tenantId, userId); scheduleMemoryProfileRefresh(session.getId(), userId, tenantId); } /** 删除整个会话及其消息。 */ @Override public void removeSession(Long userId, Long tenantId, Long sessionId) { ensureIdentity(userId, tenantId); if (sessionId == null) { throw new CoolException("AI 会话 ID 不能为空"); } AiChatSession session = aiChatSessionMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatSession::getId, sessionId) .eq(AiChatSession::getUserId, userId) .eq(AiChatSession::getTenantId, tenantId) .eq(AiChatSession::getDeleted, 0) .last("limit 1")); if (session == null) { throw new CoolException("AI 会话不存在或无权删除"); } Date now = new Date(); AiChatSession updateSession = new AiChatSession() .setId(sessionId) .setDeleted(1) .setUpdateBy(userId) .setUpdateTime(now); aiChatSessionMapper.updateById(updateSession); List messages = aiChatMessageMapper.selectList(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, sessionId) .eq(AiChatMessage::getDeleted, 0)); for (AiChatMessage message : messages) { AiChatMessage updateMessage = new AiChatMessage() .setId(message.getId()) .setDeleted(1); aiChatMessageMapper.updateById(updateMessage); } evictConversationCaches(tenantId, userId); } /** 更新会话标题并返回最新会话摘要。 */ @Override public AiChatSessionDto renameSession(Long userId, Long tenantId, Long sessionId, AiChatSessionRenameRequest request) { ensureIdentity(userId, tenantId); if (request == null || !StringUtils.hasText(request.getTitle())) { throw new CoolException("会话标题不能为空"); } AiChatSession session = requireOwnedSession(sessionId, userId, tenantId); Date now = new Date(); AiChatSession update = new AiChatSession() .setId(sessionId) .setTitle(buildSessionTitle(request.getTitle())) .setUpdateBy(userId) .setUpdateTime(now); aiChatSessionMapper.updateById(update); AiChatSessionDto sessionDto = buildSessionDto(requireOwnedSession(sessionId, userId, tenantId)); evictConversationCaches(tenantId, userId); return sessionDto; } /** 更新会话置顶状态。 */ @Override public AiChatSessionDto pinSession(Long userId, Long tenantId, Long sessionId, AiChatSessionPinRequest request) { ensureIdentity(userId, tenantId); if (request == null || request.getPinned() == null) { throw new CoolException("置顶状态不能为空"); } AiChatSession session = requireOwnedSession(sessionId, userId, tenantId); Date now = new Date(); AiChatSession update = new AiChatSession() .setId(sessionId) .setPinned(Boolean.TRUE.equals(request.getPinned()) ? 1 : 0) .setUpdateBy(userId) .setUpdateTime(now); aiChatSessionMapper.updateById(update); AiChatSessionDto sessionDto = buildSessionDto(requireOwnedSession(sessionId, userId, tenantId)); evictConversationCaches(tenantId, userId); return sessionDto; } /** 清空某个会话的全部消息和派生记忆字段。 */ @Override public void clearSessionMemory(Long userId, Long tenantId, Long sessionId) { ensureIdentity(userId, tenantId); AiChatSession session = requireOwnedSession(sessionId, userId, tenantId); List messages = aiChatMessageMapper.selectList(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, sessionId) .eq(AiChatMessage::getDeleted, 0)); for (AiChatMessage message : messages) { aiChatMessageMapper.updateById(new AiChatMessage() .setId(message.getId()) .setDeleted(1)); } aiChatSessionMapper.updateById(new AiChatSession() .setId(sessionId) .setMemorySummary(null) .setMemoryFacts(null) .setUpdateBy(userId) .setUpdateTime(new Date()) .setLastMessageTime(session.getCreateTime())); evictConversationCaches(tenantId, userId); } /** 只保留最近一轮问答,用于手动裁剪长会话。 */ @Override public void retainLatestRound(Long userId, Long tenantId, Long sessionId) { ensureIdentity(userId, tenantId); requireOwnedSession(sessionId, userId, tenantId); List records = listMessageRecords(sessionId); if (records.isEmpty()) { return; } List retained = tailMessageRecordsByRounds(records, 1); for (AiChatMessage message : records) { boolean shouldKeep = retained.stream().anyMatch(item -> item.getId().equals(message.getId())); if (!shouldKeep) { aiChatMessageMapper.updateById(new AiChatMessage() .setId(message.getId()) .setDeleted(1)); } } evictConversationCaches(tenantId, userId); scheduleMemoryProfileRefresh(sessionId, userId, tenantId); } private void evictConversationCaches(Long tenantId, Long userId) { // 会话标题、摘要、最近消息和 runtime 都会互相影响,统一按用户维度一起失效更稳妥。 aiRedisSupport.evictUserConversationCaches(tenantId, userId); } private void scheduleMemoryProfileRefresh(Long sessionId, Long userId, Long tenantId) { if (sessionId == null) { return; } if (!refreshingSessionIds.add(sessionId)) { pendingRefreshSessionIds.add(sessionId); return; } aiMemoryTaskExecutor.execute(() -> runMemoryProfileRefreshLoop(sessionId, userId, tenantId)); } private void runMemoryProfileRefreshLoop(Long sessionId, Long userId, Long tenantId) { try { boolean shouldContinue; do { pendingRefreshSessionIds.remove(sessionId); try { refreshMemoryProfile(sessionId, userId); evictConversationCaches(tenantId, userId); } catch (Exception e) { log.warn("AI memory profile refresh failed, sessionId={}, userId={}, tenantId={}, message={}", sessionId, userId, tenantId, e.getMessage(), e); } shouldContinue = pendingRefreshSessionIds.remove(sessionId); } while (shouldContinue); } finally { refreshingSessionIds.remove(sessionId); if (pendingRefreshSessionIds.remove(sessionId) && refreshingSessionIds.add(sessionId)) { aiMemoryTaskExecutor.execute(() -> runMemoryProfileRefreshLoop(sessionId, userId, tenantId)); } } } private AiChatSession findLatestSession(Long userId, Long tenantId, String promptCode) { return aiChatSessionMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatSession::getUserId, userId) .eq(AiChatSession::getTenantId, tenantId) .eq(AiChatSession::getPromptCode, promptCode) .eq(AiChatSession::getDeleted, 0) .eq(AiChatSession::getStatus, StatusType.ENABLE.val) .orderByDesc(AiChatSession::getLastMessageTime) .orderByDesc(AiChatSession::getId) .last("limit 1")); } private AiChatSession getSession(Long sessionId, Long userId, Long tenantId, String promptCode) { AiChatSession session = aiChatSessionMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatSession::getId, sessionId) .eq(AiChatSession::getUserId, userId) .eq(AiChatSession::getTenantId, tenantId) .eq(AiChatSession::getPromptCode, promptCode) .eq(AiChatSession::getDeleted, 0) .eq(AiChatSession::getStatus, StatusType.ENABLE.val) .last("limit 1")); if (session == null) { throw new CoolException("AI 会话不存在或无权访问"); } return session; } private AiChatSession requireOwnedSession(Long sessionId, Long userId, Long tenantId) { if (sessionId == null) { throw new CoolException("AI 会话 ID 不能为空"); } AiChatSession session = aiChatSessionMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatSession::getId, sessionId) .eq(AiChatSession::getUserId, userId) .eq(AiChatSession::getTenantId, tenantId) .eq(AiChatSession::getDeleted, 0) .eq(AiChatSession::getStatus, StatusType.ENABLE.val) .last("limit 1")); if (session == null) { throw new CoolException("AI 会话不存在或无权访问"); } return session; } private List listMessages(Long sessionId) { List records = listMessageRecords(sessionId); if (Cools.isEmpty(records)) { return List.of(); } List messages = new ArrayList<>(); for (AiChatMessage record : records) { if (!StringUtils.hasText(record.getContent())) { continue; } AiChatMessageDto item = new AiChatMessageDto(); item.setRole(record.getRole()); item.setContent(record.getContent()); messages.add(item); } return messages; } private List normalizeMessages(List memoryMessages) { /** 清洗前端上传的内存消息,只允许 user/assistant 两类角色落库。 */ List normalized = new ArrayList<>(); if (Cools.isEmpty(memoryMessages)) { return normalized; } for (AiChatMessageDto item : memoryMessages) { if (item == null || !StringUtils.hasText(item.getContent())) { continue; } String role = item.getRole() == null ? "user" : item.getRole().toLowerCase(); if ("system".equals(role)) { continue; } AiChatMessageDto normalizedItem = new AiChatMessageDto(); normalizedItem.setRole("assistant".equals(role) ? "assistant" : "user"); normalizedItem.setContent(item.getContent().trim()); normalized.add(normalizedItem); } return normalized; } private List listMessageRecords(Long sessionId) { return aiChatMessageMapper.selectList(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, sessionId) .eq(AiChatMessage::getDeleted, 0) .orderByAsc(AiChatMessage::getSeqNo) .orderByAsc(AiChatMessage::getId)); } private int findNextSeqNo(Long sessionId) { AiChatMessage lastMessage = aiChatMessageMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, sessionId) .eq(AiChatMessage::getDeleted, 0) .orderByDesc(AiChatMessage::getSeqNo) .orderByDesc(AiChatMessage::getId) .last("limit 1")); return lastMessage == null || lastMessage.getSeqNo() == null ? 1 : lastMessage.getSeqNo() + 1; } private AiChatMessage buildMessageEntity(Long sessionId, int seqNo, String role, String content, Long userId, Long tenantId, Date createTime) { return new AiChatMessage() .setSessionId(sessionId) .setSeqNo(seqNo) .setRole(role) .setContent(content) .setContentLength(content == null ? 0 : content.length()) .setUserId(userId) .setTenantId(tenantId) .setDeleted(0) .setCreateBy(userId) .setCreateTime(createTime); } private String resolveUpdatedTitle(String currentTitle, List memoryMessages) { if (StringUtils.hasText(currentTitle)) { return currentTitle; } for (AiChatMessageDto item : memoryMessages) { if ("user".equals(item.getRole()) && StringUtils.hasText(item.getContent())) { return buildSessionTitle(item.getContent()); } } return null; } private String buildSessionTitle(String titleSeed) { /** * 把首轮用户问题压缩成适合作为会话标题的短摘要。 * 这里会去掉换行、连续空白,并优先在自然语义断点处截断。 */ if (!StringUtils.hasText(titleSeed)) { throw new CoolException("AI 会话标题不能为空"); } String title = titleSeed.trim() .replace("\r", " ") .replace("\n", " ") .replaceAll("\\s+", " "); int punctuationIndex = findSummaryBreakIndex(title); if (punctuationIndex > 0) { title = title.substring(0, punctuationIndex).trim(); } return title.length() > 48 ? title.substring(0, 48) : title; } private int findSummaryBreakIndex(String title) { String[] separators = {"。", "!", "?", ".", "!", "?"}; int result = -1; for (String separator : separators) { int index = title.indexOf(separator); if (index > 0 && (result < 0 || index < result)) { result = index; } } return result; } private AiChatSessionDto buildSessionDto(AiChatSession session) { AiChatMessage lastMessage = aiChatMessageMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, session.getId()) .eq(AiChatMessage::getDeleted, 0) .orderByDesc(AiChatMessage::getSeqNo) .orderByDesc(AiChatMessage::getId) .last("limit 1")); return AiChatSessionDto.builder() .sessionId(session.getId()) .title(session.getTitle()) .promptCode(session.getPromptCode()) .pinned(session.getPinned() != null && session.getPinned() == 1) .lastMessagePreview(buildLastMessagePreview(lastMessage)) .lastMessageTime(session.getLastMessageTime()) .build(); } private String buildLastMessagePreview(AiChatMessage message) { if (message == null || !StringUtils.hasText(message.getContent())) { return null; } String preview = message.getContent().trim() .replace("\r", " ") .replace("\n", " ") .replaceAll("\\s+", " "); String prefix = "assistant".equalsIgnoreCase(message.getRole()) ? "AI: " : "你: "; String normalized = prefix + preview; return normalized.length() > 80 ? normalized.substring(0, 80) : normalized; } private void refreshMemoryProfile(Long sessionId, Long userId) { /** * 重新计算会话的摘要记忆和关键事实。 * 这是“持久化消息”和“模型上下文治理”之间的桥梁方法。 * 现在它运行在后台线程里,因此允许短时间最终一致,而不是强制本轮同步完成。 */ List messages = listMessages(sessionId); List shortMemoryMessages = tailMessagesByRounds(messages, AiDefaults.MEMORY_RECENT_ROUNDS); List historyMessages = messages.size() > shortMemoryMessages.size() ? messages.subList(0, messages.size() - shortMemoryMessages.size()) : List.of(); String memorySummary = historyMessages.size() >= AiDefaults.MEMORY_SUMMARY_TRIGGER_MESSAGES ? buildMemorySummary(historyMessages) : null; String memoryFacts = buildMemoryFacts(messages); AiChatMessage lastMessage = aiChatMessageMapper.selectOne(new LambdaQueryWrapper() .eq(AiChatMessage::getSessionId, sessionId) .eq(AiChatMessage::getDeleted, 0) .orderByDesc(AiChatMessage::getSeqNo) .orderByDesc(AiChatMessage::getId) .last("limit 1")); aiChatSessionMapper.updateById(new AiChatSession() .setId(sessionId) .setMemorySummary(memorySummary) .setMemoryFacts(memoryFacts) .setLastMessageTime(lastMessage == null ? null : lastMessage.getCreateTime()) .setUpdateBy(userId) .setUpdateTime(new Date())); } private List tailMessagesByRounds(List source, int rounds) { /** 按“用户发言轮次”裁剪最近消息,而不是简单按条数截断。 */ if (Cools.isEmpty(source) || rounds <= 0) { return List.of(); } int userCount = 0; int startIndex = source.size(); for (int i = source.size() - 1; i >= 0; i--) { AiChatMessageDto item = source.get(i); startIndex = i; if (item != null && "user".equalsIgnoreCase(item.getRole())) { userCount++; if (userCount >= rounds) { break; } } } return new ArrayList<>(source.subList(Math.max(0, startIndex), source.size())); } private List tailMessageRecordsByRounds(List source, int rounds) { if (Cools.isEmpty(source) || rounds <= 0) { return List.of(); } int userCount = 0; int startIndex = source.size(); for (int i = source.size() - 1; i >= 0; i--) { AiChatMessage item = source.get(i); startIndex = i; if (item != null && "user".equalsIgnoreCase(item.getRole())) { userCount++; if (userCount >= rounds) { break; } } } return new ArrayList<>(source.subList(Math.max(0, startIndex), source.size())); } private String buildMemorySummary(List historyMessages) { /** 为较早历史生成可直接插入系统消息的文本摘要。 */ StringBuilder builder = new StringBuilder("较早对话摘要:\n"); for (AiChatMessageDto item : historyMessages) { if (item == null || !StringUtils.hasText(item.getContent())) { continue; } String prefix = "assistant".equalsIgnoreCase(item.getRole()) ? "- AI: " : "- 用户: "; String content = compactText(item.getContent(), 120); if (!StringUtils.hasText(content)) { continue; } builder.append(prefix).append(content).append("\n"); if (builder.length() >= AiDefaults.MEMORY_SUMMARY_MAX_LENGTH) { break; } } return compactText(builder.toString(), AiDefaults.MEMORY_SUMMARY_MAX_LENGTH); } private String buildMemoryFacts(List messages) { /** 从最近用户关注点中提炼关键事实,作为轻量持久记忆。 */ if (Cools.isEmpty(messages)) { return null; } StringBuilder builder = new StringBuilder("关键事实:\n"); int userFacts = 0; for (int i = messages.size() - 1; i >= 0 && userFacts < 4; i--) { AiChatMessageDto item = messages.get(i); if (item == null || !"user".equalsIgnoreCase(item.getRole()) || !StringUtils.hasText(item.getContent())) { continue; } builder.append("- 用户关注: ").append(compactText(item.getContent(), 100)).append("\n"); userFacts++; } return userFacts == 0 ? null : compactText(builder.toString(), AiDefaults.MEMORY_FACTS_MAX_LENGTH); } private String compactText(String content, int maxLength) { if (!StringUtils.hasText(content)) { return null; } String normalized = content.trim() .replace("\r", " ") .replace("\n", " ") .replaceAll("\\s+", " "); return normalized.length() > maxLength ? normalized.substring(0, maxLength) : normalized; } private void ensureIdentity(Long userId, Long tenantId) { if (userId == null) { throw new CoolException("当前登录用户不存在"); } if (tenantId == null) { throw new CoolException("当前租户不存在"); } } private String requirePromptCode(String promptCode) { if (!StringUtils.hasText(promptCode)) { throw new CoolException("Prompt 编码不能为空"); } return promptCode; } }