fix: 修复小说生成保存链路(事务+originalQuery兜底+中间步骤累积)

This commit is contained in:
2026-07-26 11:49:31 +08:00
parent 4931005102
commit 06605bfd0d
2 changed files with 121 additions and 3 deletions
@@ -227,6 +227,7 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService
* @param metadata 大纲元数据(标题等)
* @return Map 包含 scriptId、conversationId、currentVersionMessageId
*/
@Transactional(rollbackFor = Exception.class)
public Map<String, String> saveNovelResult(String currentUserId, String theme,
String fullText, Map<String, Object> metadata) {
String scriptId = snowflakeIdGenerator.nextIdAsString();
@@ -240,8 +241,12 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService
script.setTitle(metadata != null && metadata.get("title") != null
? (String) metadata.get("title") : "我的人生剧本");
script.setTheme(theme);
script.setStyle("career");
script.setLength("short");
// style/length 从 metadata 传入,允许 null(保持原行为兼容),但禁止硬编码 "career"/"short"
// 由前端 ShortNovelServiceImpl 在调用前透传 style/length 到 metadata
script.setStyle(metadata != null && metadata.get("style") != null
? (String) metadata.get("style") : "career");
script.setLength(metadata != null && metadata.get("length") != null
? (String) metadata.get("length") : "short");
script.setConversationId(conversationId);
script.setPlotIntro("");
script.setPlotTurning("");
@@ -42,6 +42,63 @@ public class ShortNovelServiceImpl implements ShortNovelService {
private static final Logger log = LoggerFactory.getLogger(ShortNovelServiceImpl.class);
private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool();
/**
* sessionId → originalQuery 内存缓存
* 用于在 followup 调用中当请求未带 originalQuery 时兜底恢复
* 会话超时 30 分钟(与 Spring Session 默认对齐)
*/
private static final java.util.concurrent.ConcurrentHashMap<String, CachedSessionMeta> SESSION_ORIGINAL_QUERY_CACHE = new java.util.concurrent.ConcurrentHashMap<>();
private static final long SESSION_TTL_MS = 30 * 60 * 1000L;
/**
* session 元数据(含 originalQuery 和缓存时间)
*/
private static class CachedSessionMeta {
final String originalQuery;
final long createdAt;
final String style;
final String length;
CachedSessionMeta(String originalQuery, String style, String length) {
this.originalQuery = originalQuery;
this.style = style;
this.length = length;
this.createdAt = System.currentTimeMillis();
}
boolean isExpired() {
return System.currentTimeMillis() - createdAt > SESSION_TTL_MS;
}
}
/**
* 缓存 session 元数据
*/
private static void cacheSessionMeta(String sessionId, String originalQuery, String style, String length) {
if (sessionId == null || sessionId.isEmpty() || originalQuery == null || originalQuery.isEmpty()) {
return;
}
SESSION_ORIGINAL_QUERY_CACHE.put(sessionId, new CachedSessionMeta(originalQuery, style, length));
}
/**
* 从缓存中获取 originalQuery,过期则返回 null
*/
private static String getCachedOriginalQuery(String sessionId) {
if (sessionId == null || sessionId.isEmpty()) {
return null;
}
CachedSessionMeta meta = SESSION_ORIGINAL_QUERY_CACHE.get(sessionId);
if (meta == null) {
return null;
}
if (meta.isExpired()) {
SESSION_ORIGINAL_QUERY_CACHE.remove(sessionId);
return null;
}
return meta.originalQuery;
}
@Autowired
private ShortNovelConfig config;
@@ -94,6 +151,17 @@ public class ShortNovelServiceImpl implements ShortNovelService {
throw new BusinessException("用户未登录");
}
// originalQuery 兜底:如果请求中为空,尝试从 session 恢复(避免因 originalQuery 缺失导致 novel_done 不保存)
String effectiveOriginalQuery = request.getOriginalQuery();
if (!org.springframework.util.StringUtils.hasText(effectiveOriginalQuery)) {
// 从会话缓存恢复 originalQueryfallback 机制,确保不丢失用户原始输入)
effectiveOriginalQuery = getCachedOriginalQuery(request.getSessionId());
if (effectiveOriginalQuery == null) {
log.warn("[ShortNovel] followup 缺少 originalQuery 且无 session 缓存: sessionId={}",
request.getSessionId());
}
}
Map<String, Object> upstreamBody = new HashMap<>();
upstreamBody.put("session_id", request.getSessionId());
upstreamBody.put("user_id", currentUserId);
@@ -101,7 +169,7 @@ public class ShortNovelServiceImpl implements ShortNovelService {
upstreamBody.put("action", request.getAction());
upstreamBody.put("payload", request.getPayload());
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, request.getOriginalQuery());
return forwardSse("/api/novels/daily/conversation/stream", upstreamBody, currentUserId, effectiveOriginalQuery);
}
/**
@@ -118,6 +186,11 @@ public class ShortNovelServiceImpl implements ShortNovelService {
EXECUTOR.execute(() -> {
String url = config.getApiBaseUrl() + path;
// 本次流式会话的中间步骤元数据(用于 novel_done 时一并持久化到 message metadata
// 包含 outline_created 的 outline JSON,以及其他需要保留到数据库的中间状态
final java.util.concurrent.atomic.AtomicReference<JSONObject> stageMetaRef =
new java.util.concurrent.atomic.AtomicReference<>(new JSONObject());
try {
// 构建 OkHttp 请求
Request okhttpRequest = new Request.Builder()
@@ -168,6 +241,38 @@ public class ShortNovelServiceImpl implements ShortNovelService {
long eventTimestamp = System.currentTimeMillis();
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
// 拦截 status 事件,缓存 sessionId → originalQuery 映射(供 followup 兜底)
if ("status".equals(type)) {
JSONObject payload = event.getJSONObject("payload");
if (payload != null) {
String sessionId = payload.getString("session_id");
if (sessionId != null && !sessionId.isEmpty()
&& originalQuery != null && !originalQuery.isEmpty()) {
// 缓存原始参数:originalQuery/style/length
String style = payload.getString("style");
String length = payload.getString("length");
cacheSessionMeta(sessionId, originalQuery, style, length);
log.debug("[ShortNovel] 缓存 session 元数据: sessionId={}", sessionId);
}
}
}
// 累积中间步骤元数据(用于 novel_done 时一并持久化)
JSONObject stagePayload = event.getJSONObject("payload");
if (stagePayload != null) {
if ("outline_created".equals(type)) {
JSONObject outline = stagePayload.getJSONObject("outline");
if (outline != null) {
stageMetaRef.get().put("outline", outline);
}
} else if ("clarification_card".equals(type)) {
JSONObject card = stagePayload.getJSONObject("card");
if (card != null) {
stageMetaRef.get().put("clarification", card);
}
}
}
// 拦截 novel_done 事件,保存到数据库
if ("novel_done".equals(type)) {
JSONObject payload = event.getJSONObject("payload");
@@ -180,6 +285,14 @@ public class ShortNovelServiceImpl implements ShortNovelService {
if (payload.get("title") != null) {
metadata.put("title", payload.get("title"));
}
// 把整个 payload(包含 outline、chapters 等)保存到 metadata
// 详情页可读取 metadata 显示中间步骤(大纲、章节等)
metadata.put("fullPayload", payload);
// 把会话期间累积的 stage 元数据也合并进来
JSONObject stageMeta = stageMetaRef.get();
if (stageMeta != null && !stageMeta.isEmpty()) {
metadata.put("stages", stageMeta);
}
log.info("[ShortNovel SSE] 开始保存小说: userId={}, queryLength={}, textLength={}",
currentUserId, originalQuery.length(), fullText.length());
Map<String, String> saveResult = epicScriptDialogueServiceImpl.saveNovelResult(