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(':', '_');
if (local.length() > 64) {
local = local.substring(0, 64);
}
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