package com.xly.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.AgentIdentity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 会话事件账本(append-only)—— 会话的**唯一事实源**:用户话、模型消息(含工具调用/结果)、
* 表单/预览/保存等按钮事件,全部按发生顺序落账。前端历史与 LLM 上下文都是它的投影
* ({@link EventProjectionService})。
*
*
存储分层:MySQL {@code ai_chat_event} 为权威持久层(每事件一行、永不删除、可溯源,
* 服务端赋 iSeq 会话内序号 + iTurn 轮次),Redis LIST {@code chat:ledger:{convId}}(30 天 TTL)
* 为热缓存加速投影读;缓存失效时回源 MySQL 并回填。两边任一失败都不阻断对话(互为兜底)。
*
*
事件类型:
* {@code user}(text, internal?)/ {@code ai}(text)/
* {@code tool_call}(text, calls=[{id,name,args}], tools)/
* {@code tool_result}(tcId, name, text, digest)/
* {@code form_submit}(entity, fields)/ {@code queued}(opIds, description, summaryLines?)/
* {@code skill_active}(name, text)/ {@code skill_done}(name);
* 旧版遗留类型 {@code assistant/clarify/form/question/tool/proposal/confirm/cancel} 仍可读(兼容渲染,自然淘汰)。
*/
@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);
/** MySQL 冷读回源的窗口上限(有界读:超长会话不整包搬运)。 */
private static final int COLD_READ_MAX = 1000;
/** 开新轮的事件类型(其余事件继承当前轮次)。 */
private static final java.util.Set TURN_OPENERS = java.util.Set.of("user", "form_submit");
private final StringRedisTemplate redis;
private final ObjectMapper mapper;
private final JdbcTemplate jdbc;
public LedgerService(StringRedisTemplate redis, ObjectMapper mapper, JdbcTemplate jdbc) {
this.redis = redis;
this.mapper = mapper;
this.jdbc = jdbc;
}
/** 追加一条事件(无身份上下文的旧签名:sMakePerson 从 convId 前缀推导)。 */
public void append(String convId, String type, Map data) {
append(convId, type, data, null);
}
/** 追加一条事件(绝不抛异常——账本失败不能影响对话主流程)。 */
public void append(String convId, String type, Map data, AgentIdentity who) {
if (convId == null || convId.isBlank()) {
return;
}
String json;
try {
Map ev = new LinkedHashMap<>();
ev.put("t", System.currentTimeMillis());
ev.put("type", type);
if (data != null) {
ev.putAll(data);
}
json = mapper.writeValueAsString(ev);
} catch (Exception e) {
log.warn("ledger serialize failed (conv={}, type={}): {}", convId, type, e.getMessage());
return;
}
boolean persisted = persist(convId, type, json, who);
cache(convId, json, persisted);
}
/** MySQL 权威落库:服务端赋 iSeq/iTurn,唯一索引 + 重试防并发撞号。失败仅告警(Redis 兜底),返回是否成功。 */
private boolean persist(String convId, String type, String json, AgentIdentity who) {
if (jdbc == null) {
return false;
}
String maker = who != null && who.userId() != null ? who.userId() : userIdOf(convId);
String brands = who == null ? null : who.brandsId();
String sub = who == null ? null : who.subsidiaryId();
for (int attempt = 0; attempt < 3; attempt++) {
try {
int seq = 1;
int turn = TURN_OPENERS.contains(type) ? 1 : 0;
List