package com.xly.service; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * 会话事件日志(append-only)—— 会话的**唯一事实源**:用户话、模型消息(含工具调用/结果的原样载荷)、 * 表单/提议/确认等按钮事件,全部按发生顺序落一条 Redis LIST。前端历史与 LLM 上下文都是它的投影 * ({@link EventProjectionService}),不再有 chat:mem 整包读改写与多处手工同步。 * *

键:{@code chat:ledger:{convId}},元素为事件 JSON {@code {t,type,...}},30 天 TTL; * rightPush 原子,任意并发写者(对话流、确认端点)互不覆盖。 * *

事件类型: * {@code user}(text, internal?)/ {@code ai}(text)/ * {@code tool_call}(payload=原样序列化的模型消息, text, tools)/ * {@code tool_result}(tcId, name, text, digest)/ * {@code form_submit}(entity, fields)/ {@code proposal}(opId, summary)/ * {@code confirm}(opId, status, msg, description)/ {@code cancel}(opId, description)/ * {@code skill_active}(name, text)/ {@code skill_done}(name); * 旧版遗留类型 {@code assistant/clarify/form/question/tool} 仍可读(兼容渲染,自然淘汰)。 */ @Service public class LedgerService { private static final Logger log = LoggerFactory.getLogger(LedgerService.class); private static final String PREFIX = "chat:ledger:"; private static final Duration TTL = Duration.ofDays(30); private final StringRedisTemplate redis; private final ObjectMapper mapper; public LedgerService(StringRedisTemplate redis, ObjectMapper mapper) { this.redis = redis; this.mapper = mapper; } /** 追加一条事件(绝不抛异常——日志失败不能影响对话主流程)。 */ public void append(String convId, String type, Map data) { if (convId == null || convId.isBlank()) { return; } try { Map ev = new LinkedHashMap<>(); ev.put("t", System.currentTimeMillis()); ev.put("type", type); if (data != null) { ev.putAll(data); } String key = PREFIX + convId; redis.opsForList().rightPush(key, mapper.writeValueAsString(ev)); redis.expire(key, TTL); } catch (Exception e) { log.warn("ledger append failed (conv={}, type={}): {}", convId, type, e.getMessage()); } } /** 全量事件(按发生顺序),供前端历史投影。 */ public List> events(String convId) { return read(convId, 0); } /** 最近 lastN 条事件,供 LLM 上下文投影(超长会话不整包搬运)。 */ public List> events(String convId, int lastN) { return read(convId, lastN); } private List> read(String convId, int lastN) { List> out = new ArrayList<>(); try { long start = lastN <= 0 ? 0 : -lastN; List raw = redis.opsForList().range(PREFIX + convId, start, -1); if (raw == null) { return out; } for (String s : raw) { try { @SuppressWarnings("unchecked") Map m = mapper.readValue(s, Map.class); out.add(m); } catch (Exception ignore) { } } } catch (Exception e) { log.warn("ledger read failed (conv={}): {}", convId, e.getMessage()); } return out; } /** 日志键是否存在(供会话列表懒清理:内容已过期的会话项从列表剔除)。 */ public boolean exists(String convId) { try { return Boolean.TRUE.equals(redis.hasKey(PREFIX + convId)); } catch (Exception e) { return true; // 读失败时不误删列表项 } } public void delete(String convId) { try { redis.delete(PREFIX + convId); } catch (Exception ignore) { } } }