fix: 修复小说生成保存链路(事务+originalQuery兜底+中间步骤累积)
This commit is contained in:
@@ -227,6 +227,7 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService
|
|||||||
* @param metadata 大纲元数据(标题等)
|
* @param metadata 大纲元数据(标题等)
|
||||||
* @return Map 包含 scriptId、conversationId、currentVersionMessageId
|
* @return Map 包含 scriptId、conversationId、currentVersionMessageId
|
||||||
*/
|
*/
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public Map<String, String> saveNovelResult(String currentUserId, String theme,
|
public Map<String, String> saveNovelResult(String currentUserId, String theme,
|
||||||
String fullText, Map<String, Object> metadata) {
|
String fullText, Map<String, Object> metadata) {
|
||||||
String scriptId = snowflakeIdGenerator.nextIdAsString();
|
String scriptId = snowflakeIdGenerator.nextIdAsString();
|
||||||
@@ -240,8 +241,12 @@ public class EpicScriptDialogueServiceImpl implements EpicScriptDialogueService
|
|||||||
script.setTitle(metadata != null && metadata.get("title") != null
|
script.setTitle(metadata != null && metadata.get("title") != null
|
||||||
? (String) metadata.get("title") : "我的人生剧本");
|
? (String) metadata.get("title") : "我的人生剧本");
|
||||||
script.setTheme(theme);
|
script.setTheme(theme);
|
||||||
script.setStyle("career");
|
// style/length 从 metadata 传入,允许 null(保持原行为兼容),但禁止硬编码 "career"/"short"
|
||||||
script.setLength("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.setConversationId(conversationId);
|
||||||
script.setPlotIntro("");
|
script.setPlotIntro("");
|
||||||
script.setPlotTurning("");
|
script.setPlotTurning("");
|
||||||
|
|||||||
@@ -42,6 +42,63 @@ public class ShortNovelServiceImpl implements ShortNovelService {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(ShortNovelServiceImpl.class);
|
private static final Logger log = LoggerFactory.getLogger(ShortNovelServiceImpl.class);
|
||||||
private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool();
|
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
|
@Autowired
|
||||||
private ShortNovelConfig config;
|
private ShortNovelConfig config;
|
||||||
|
|
||||||
@@ -94,6 +151,17 @@ public class ShortNovelServiceImpl implements ShortNovelService {
|
|||||||
throw new BusinessException("用户未登录");
|
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<String, Object> upstreamBody = new HashMap<>();
|
Map<String, Object> upstreamBody = new HashMap<>();
|
||||||
upstreamBody.put("session_id", request.getSessionId());
|
upstreamBody.put("session_id", request.getSessionId());
|
||||||
upstreamBody.put("user_id", currentUserId);
|
upstreamBody.put("user_id", currentUserId);
|
||||||
@@ -101,7 +169,7 @@ public class ShortNovelServiceImpl implements ShortNovelService {
|
|||||||
upstreamBody.put("action", request.getAction());
|
upstreamBody.put("action", request.getAction());
|
||||||
upstreamBody.put("payload", request.getPayload());
|
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(() -> {
|
EXECUTOR.execute(() -> {
|
||||||
String url = config.getApiBaseUrl() + path;
|
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 {
|
try {
|
||||||
// 构建 OkHttp 请求
|
// 构建 OkHttp 请求
|
||||||
Request okhttpRequest = new Request.Builder()
|
Request okhttpRequest = new Request.Builder()
|
||||||
@@ -168,6 +241,38 @@ public class ShortNovelServiceImpl implements ShortNovelService {
|
|||||||
long eventTimestamp = System.currentTimeMillis();
|
long eventTimestamp = System.currentTimeMillis();
|
||||||
log.info("[ShortNovel SSE] 处理事件: type={}, timestamp={}", type, eventTimestamp);
|
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 事件,保存到数据库
|
// 拦截 novel_done 事件,保存到数据库
|
||||||
if ("novel_done".equals(type)) {
|
if ("novel_done".equals(type)) {
|
||||||
JSONObject payload = event.getJSONObject("payload");
|
JSONObject payload = event.getJSONObject("payload");
|
||||||
@@ -180,6 +285,14 @@ public class ShortNovelServiceImpl implements ShortNovelService {
|
|||||||
if (payload.get("title") != null) {
|
if (payload.get("title") != null) {
|
||||||
metadata.put("title", payload.get("title"));
|
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={}",
|
log.info("[ShortNovel SSE] 开始保存小说: userId={}, queryLength={}, textLength={}",
|
||||||
currentUserId, originalQuery.length(), fullText.length());
|
currentUserId, originalQuery.length(), fullText.length());
|
||||||
Map<String, String> saveResult = epicScriptDialogueServiceImpl.saveNovelResult(
|
Map<String, String> saveResult = epicScriptDialogueServiceImpl.saveNovelResult(
|
||||||
|
|||||||
Reference in New Issue
Block a user