LedgerService.java 4.38 KB
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 整包读改写与多处手工同步。
 *
 * <p>键:{@code chat:ledger:{convId}},元素为事件 JSON {@code {t,type,...}},30 天 TTL;
 * rightPush 原子,任意并发写者(对话流、确认端点)互不覆盖。
 *
 * <p>事件类型:
 * {@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<String, Object> data) {
        if (convId == null || convId.isBlank()) {
            return;
        }
        try {
            Map<String, Object> 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<Map<String, Object>> events(String convId) {
        return read(convId, 0);
    }

    /** 最近 lastN 条事件,供 LLM 上下文投影(超长会话不整包搬运)。 */
    public List<Map<String, Object>> events(String convId, int lastN) {
        return read(convId, lastN);
    }

    private List<Map<String, Object>> read(String convId, int lastN) {
        List<Map<String, Object>> out = new ArrayList<>();
        try {
            long start = lastN <= 0 ? 0 : -lastN;
            List<String> raw = redis.opsForList().range(PREFIX + convId, start, -1);
            if (raw == null) {
                return out;
            }
            for (String s : raw) {
                try {
                    @SuppressWarnings("unchecked")
                    Map<String, Object> 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) {
        }
    }
}