package com.xly.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.config.RedisChatMemoryStore; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.UserMessage; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 会话(conversation)元数据管理 —— 支持「多条命名会话/用户」。 * *

每用户一个会话列表存 Redis Hash {@code chat:convs:{userId}}(field=convId,value=JSON{id,title,updatedAt}); * 消息本体存在 {@link RedisChatMemoryStore}({@code chat:mem:{convId}})。标题取自该会话的首条用户消息。 */ @Service public class ConversationService { private static final String CONVS_KEY = "chat:convs:"; /** 懒清理宽限:新建会话可能短暂没有任何事件,updatedAt 在此窗口内的不清。 */ private static final long CLEANUP_GRACE_MS = 3L * 24 * 3600 * 1000; private final StringRedisTemplate redis; private final RedisChatMemoryStore memoryStore; private final ObjectMapper mapper; private final LedgerService ledger; private final EventProjectionService projection; public ConversationService(StringRedisTemplate redis, RedisChatMemoryStore memoryStore, ObjectMapper mapper, LedgerService ledger, EventProjectionService projection) { this.redis = redis; this.memoryStore = memoryStore; this.mapper = mapper; this.ledger = ledger; this.projection = projection; } /** * 把客户端给的 conversationId 绑定到**服务端身份**:真实 id = {userId}:{客户端 id 的清洗值}。 * 于是伪造别人的 conversationId 只会落到自己命名空间下的新会话,碰不到他人记忆/账本。 */ public String scopedId(com.xly.agent.AgentIdentity identity, String rawConvId) { String uid = identity == null || identity.userId() == null || identity.userId().isBlank() ? "anon" : identity.userId(); String raw = rawConvId == null ? "" : rawConvId.trim(); // 已经是本人命名空间下的 id → 原样用;否则取其局部名(去掉别人的前缀)再挂到自己名下 if (raw.startsWith(uid + ":")) { return raw; } String local = raw.isEmpty() ? "default" : raw.replace(':', '_'); // 总长必须装进 ai_chat_event/ai_op_queue 的 sConversationId varchar(96)(uid 可能是 36 位 uuid) int max = Math.min(64, Math.max(1, 96 - uid.length() - 1)); if (local.length() > max) { local = local.substring(0, max); } return uid + ":" + local; } /** 新建一个空会话,返回 convId(已绑定该用户命名空间)。 */ public String create(String userId) { String uid = userId == null || userId.isBlank() ? "anon" : userId; String convId = uid + ":c-" + System.currentTimeMillis() + "-" + Integer.toHexString((int) (Math.random() * 0xFFFFF)); writeMeta(uid, convId, "新会话"); return convId; } /** 会话是否属于该用户(元数据存在 + id 前缀绑定,双重判定)。 */ public boolean owns(String userId, String convId) { if (userId == null || userId.isBlank() || convId == null || convId.isBlank()) { return false; } if (!convId.startsWith(userId + ":")) { return false; } return redis.opsForHash().hasKey(CONVS_KEY + userId, convId); } /** 每次对话时调用:会话不存在则建、标题空则用首条消息命名,并刷新 updatedAt。 */ public void touch(String userId, String convId, String firstMsg) { Object existing = redis.opsForHash().get(CONVS_KEY + userId, convId); String title = null; if (existing != null) { try { title = mapper.readTree(existing.toString()).path("title").asText(null); } catch (Exception ignore) { } } if (title == null || title.isBlank() || "新会话".equals(title)) { title = deriveTitle(firstMsg); } writeMeta(userId, convId, title); } private void writeMeta(String userId, String convId, String title) { try { Map m = new LinkedHashMap<>(); m.put("id", convId); m.put("title", title); m.put("updatedAt", System.currentTimeMillis()); redis.opsForHash().put(CONVS_KEY + userId, convId, mapper.writeValueAsString(m)); } catch (Exception ignore) { } } /** 该用户的会话列表,按最近更新倒序。内容键已过期(30 天 TTL)的项懒清理剔除。 */ public List> list(String userId) { Map all = redis.opsForHash().entries(CONVS_KEY + userId); List> out = new ArrayList<>(); long now = System.currentTimeMillis(); for (Object v : all.values()) { try { @SuppressWarnings("unchecked") Map m = mapper.readValue(v.toString(), Map.class); String convId = String.valueOf(m.get("id")); if (now - num(m.get("updatedAt")) > CLEANUP_GRACE_MS && !ledger.exists(convId) && !memoryStore.exists(convId)) { redis.opsForHash().delete(CONVS_KEY + userId, convId); continue; } out.add(m); } catch (Exception ignore) { } } out.sort((a, b) -> Long.compare(num(b.get("updatedAt")), num(a.get("updatedAt")))); return out; } public void delete(String userId, String convId) { redis.opsForHash().delete(CONVS_KEY + userId, convId); memoryStore.deleteMessages(convId); ledger.delete(convId); // 旧 chat:state 键随 30 天 TTL 自然过期(StateService 已随意图门废除) } /** * 会话历史 = 事件日志的前端投影(与 LLM 上下文同源,见 {@link EventProjectionService}); * 无日志的旧会话退回消息记忆兼容读取。映射为 {role:user|ai, content}。 */ public List> history(String convId) { List> events = ledger.events(convId); if (!events.isEmpty()) { return projection.historyView(events); } List> out = new ArrayList<>(); for (ChatMessage m : memoryStore.getMessages(convId)) { if (m instanceof UserMessage um && um.hasSingleText()) { out.add(Map.of("role", "user", "content", um.singleText())); } else if (m instanceof AiMessage am && !am.hasToolExecutionRequests() && am.text() != null && !am.text().isBlank()) { out.add(Map.of("role", "ai", "content", am.text())); } } return out; } private String deriveTitle(String s) { if (s == null) { return "新会话"; } String t = s.strip().replace("\n", " "); if (t.isEmpty()) { return "新会话"; } return t.length() > 18 ? t.substring(0, 18) + "…" : t; } private long num(Object o) { try { return Long.parseLong(String.valueOf(o)); } catch (Exception e) { return 0L; } } }