diff --git a/server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java b/server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java index 52b6c12..97c534f 100644 --- a/server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java +++ b/server/src/main/java/com/emotion/service/impl/EpicScriptDialogueServiceImpl.java @@ -227,6 +227,7 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService * @param metadata 大纲元数据(标题等) * @return Map 包含 scriptId、conversationId、currentVersionMessageId */ + @Transactional(rollbackFor = Exception.class) public Map saveNovelResult(String currentUserId, String theme, String fullText, Map 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(""); diff --git a/server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java b/server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java index 136647c..f760cd9 100644 --- a/server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java +++ b/server/src/main/java/com/emotion/service/impl/ShortNovelServiceImpl.java @@ -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 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)) { + // 从会话缓存恢复 originalQuery(fallback 机制,确保不丢失用户原始输入) + effectiveOriginalQuery = getCachedOriginalQuery(request.getSessionId()); + if (effectiveOriginalQuery == null) { + log.warn("[ShortNovel] followup 缺少 originalQuery 且无 session 缓存: sessionId={}", + request.getSessionId()); + } + } + Map 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 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 saveResult = epicScriptDialogueServiceImpl.saveNovelResult(