Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 50 additions & 5 deletions application/core/services/novel_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Novel 应用服务"""
import time as _time
import json
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any
Expand Down Expand Up @@ -143,6 +144,7 @@ def create_novel(
)

self.novel_repository.save(novel)
self._invalidate_novels_cache()

return NovelDTO.from_domain(novel)

Expand Down Expand Up @@ -186,19 +188,56 @@ def _check_has_outline(self, novel_id: str) -> bool:
except Exception:
return False

# ── 小说列表 TTL 缓存(模块级,跨请求共享)──
_NOVELS_LIST_CACHE_TTL = 10.0 # 秒

_novels_list_cache: Dict[str, Dict[str, Any]] = {}

@staticmethod
def _invalidate_novels_cache() -> None:
"""创建/更新/删除小说时清除列表缓存。"""
NovelService._novels_list_cache.pop("_all_novels_dtos", None)

def list_novels(self) -> List[NovelDTO]:
"""列出所有小说
"""列出所有小说(批量查询 + TTL 缓存)。

优化:使用 LEFT JOIN 一次性加载 novels + chapters + bible + outline,
查询数从 1+3N 降至 3 次。模块级 TTL 缓存避免重复查询。

Returns:
NovelDTO 列表
"""
novels = self.novel_repository.list_all()
cache_key = "_all_novels_dtos"
cached = self._novels_list_cache.get(cache_key)
if cached is not None and _time.time() - cached["ts"] < self._NOVELS_LIST_CACHE_TTL:
return cached["data"]

Comment on lines +212 to +214
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not return/store mutable cached DTO objects by reference.

Line 211-Line 213 and Line 239 currently share the same mutable list/DTO instances across requests. Any in-place mutation by one caller can pollute later responses.

Proposed fix
+import copy
...
-        if cached is not None and _time.time() - cached["ts"] < self._NOVELS_LIST_CACHE_TTL:
-            return cached["data"]
+        if cached is not None and _time.time() - cached["ts"] < self._NOVELS_LIST_CACHE_TTL:
+            return copy.deepcopy(cached["data"])
...
-        self._novels_list_cache[cache_key] = {"data": dtos, "ts": _time.time()}
+        self._novels_list_cache[cache_key] = {
+            "data": copy.deepcopy(dtos),
+            "ts": _time.time(),
+        }

Also applies to: 239-239

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@application/core/services/novel_service.py` around lines 211 - 213, Cached
DTOs/lists are being stored and returned by reference (cached["data"]) which
allows callers to mutate shared objects; change the cache writes and reads in
NovelService so you store and return defensive copies instead of the original
objects — e.g., when setting cached["data"] wrap the DTO/list in a deep copy or
reconstruct DTOs (use copy.deepcopy or DTO.from_dict/map over items) and when
returning cached["data"] return a copy as well (the same change needed for the
other cache access at the second occurrence, the code that reads/writes
cached["data"] around line 239); ensure timestamps (cached["ts"]) remain
unchanged and only the data payload is cloned.

# 尝试批量优化查询
try:
from infrastructure.persistence.database.query_optimizations import (
list_all_novels_optimized,
)
db = getattr(self.novel_repository, "db", None)
if db is not None:
novels = list_all_novels_optimized(db)
else:
novels = self.novel_repository.list_all()
for novel in novels:
self._hydrate_chapters(novel)
except Exception:
novels = self.novel_repository.list_all()
for novel in novels:
self._hydrate_chapters(novel)

dtos = []
for novel in novels:
dto = NovelDTO.from_domain(self._hydrate_chapters(novel))
dto.has_bible = self._check_has_bible(novel.novel_id.value)
dto.has_outline = self._check_has_outline(novel.novel_id.value)
dto = NovelDTO.from_domain(novel)
# 批量查询已预计算 has_bible / has_outline
dto.has_bible = getattr(novel, "_has_bible", self._check_has_bible(novel.novel_id.value))
dto.has_outline = getattr(novel, "_has_outline", self._check_has_outline(novel.novel_id.value))
Comment on lines +236 to +237
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid eager fallback evaluation in getattr here.

At Line 235 and Line 236, _check_has_bible(...) / _check_has_outline(...) are executed even when _has_bible / _has_outline exist, because getattr’s default is evaluated eagerly. This negates the batch-query optimization and can reintroduce N+1-style I/O.

Proposed fix
-            dto.has_bible = getattr(novel, "_has_bible", self._check_has_bible(novel.novel_id.value))
-            dto.has_outline = getattr(novel, "_has_outline", self._check_has_outline(novel.novel_id.value))
+            dto.has_bible = (
+                novel._has_bible
+                if hasattr(novel, "_has_bible")
+                else self._check_has_bible(novel.novel_id.value)
+            )
+            dto.has_outline = (
+                novel._has_outline
+                if hasattr(novel, "_has_outline")
+                else self._check_has_outline(novel.novel_id.value)
+            )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
dto.has_bible = getattr(novel, "_has_bible", self._check_has_bible(novel.novel_id.value))
dto.has_outline = getattr(novel, "_has_outline", self._check_has_outline(novel.novel_id.value))
dto.has_bible = (
novel._has_bible
if hasattr(novel, "_has_bible")
else self._check_has_bible(novel.novel_id.value)
)
dto.has_outline = (
novel._has_outline
if hasattr(novel, "_has_outline")
else self._check_has_outline(novel.novel_id.value)
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@application/core/services/novel_service.py` around lines 235 - 236, The
getattr calls eagerly evaluate the default expressions causing
_check_has_bible/_check_has_outline to run even when
novel._has_bible/_has_outline exist; change the pattern to use a unique sentinel
(or hasattr/try-except) so you first retrieve value = getattr(novel,
"_has_bible", SENTINEL) and only call
self._check_has_bible(novel.novel_id.value) if value is SENTINEL, then assign
dto.has_bible = value_or_fallback; do the same for "_has_outline" with
self._check_has_outline to avoid unnecessary immediate evaluation and preserve
batch-query optimization.

dtos.append(dto)

self._novels_list_cache[cache_key] = {"data": dtos, "ts": _time.time()}
return dtos

def delete_novel(self, novel_id: str) -> None:
Expand All @@ -208,6 +247,7 @@ def delete_novel(self, novel_id: str) -> None:
novel_id: 小说 ID
"""
self.novel_repository.delete(NovelId(novel_id))
self._invalidate_novels_cache()

def add_chapter(
self,
Expand Down Expand Up @@ -260,6 +300,7 @@ def add_chapter(
if not any(getattr(c, "number", None) == chapter.number for c in novel.chapters):
novel.chapters.append(chapter)
self.novel_repository.save(novel)
self._invalidate_novels_cache()

# 同步创建 StoryNode 章节节点,并关联到当前活跃的幕
if self.story_node_repository:
Expand Down Expand Up @@ -358,6 +399,8 @@ def update_novel(
novel.generation_prefs, generation_prefs
)

self.novel_repository.save(novel)
self._invalidate_novels_cache()
# 增量 patch:避免全量 save 把 autopilot_status 等未改字段写回 stopped
Comment on lines +402 to 404
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove redundant full save before patch to avoid unintended field overwrite.

Line 402 performs save(novel) before the incremental patch(...), which contradicts the Line 404 rationale and can reintroduce overwrite of non-updated fields (e.g., autopilot-related state).

Proposed fix
-        self.novel_repository.save(novel)
-        self._invalidate_novels_cache()
         # 增量 patch:避免全量 save 把 autopilot_status 等未改字段写回 stopped
         patch_fields: Dict[str, Any] = {}
...
         if patch_fields:
             self.novel_repository.patch(NovelId(novel_id), **patch_fields)
+            self._invalidate_novels_cache()

Also applies to: 420-421

🧰 Tools
🪛 Ruff (0.15.13)

[warning] 404-404: Comment contains ambiguous (FULLWIDTH COLON). Did you mean : (COLON)?

(RUF003)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@application/core/services/novel_service.py` around lines 402 - 404, Remove
the redundant full save that occurs before the incremental patch to avoid
overwriting untouched fields: delete the call to novel_repository.save(novel)
(the full-save) and instead call novel_repository.patch(...) directly after
updating the novel model, keeping the existing self._invalidate_novels_cache()
behavior; apply the same change where a save is followed by a patch in the other
location (the similar save/patch pair around lines 420-421) so only the
incremental patch is used to persist changes and avoid clobbering
autopilot_status and related fields.

patch_fields: Dict[str, Any] = {}
if title is not None:
Expand Down Expand Up @@ -398,6 +441,7 @@ def update_novel_stage(self, novel_id: str, stage: str) -> NovelDTO:

novel.stage = NovelStage(stage)
self.novel_repository.save(novel)
self._invalidate_novels_cache()

return NovelDTO.from_domain(self._hydrate_chapters(novel))

Expand All @@ -420,6 +464,7 @@ def update_auto_approve_mode(self, novel_id: str, auto_approve_mode: bool) -> No

novel.auto_approve_mode = auto_approve_mode
self.novel_repository.save(novel)
self._invalidate_novels_cache()

return NovelDTO.from_domain(self._hydrate_chapters(novel))

Expand Down
2 changes: 1 addition & 1 deletion application/engine/services/beat_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

import logging
from dataclasses import dataclass, field
from typing import List, Optional, Protocol, Tuple
from typing import Any, List, Optional, Protocol, Tuple

from application.engine.services.context_builder import Beat

Expand Down
2 changes: 2 additions & 0 deletions application/engine/services/shared_state_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
- 写入先更新内存,再异步持久化
- 数据结构轻量化,避免内存过大
"""
from __future__ import annotations

import logging
import time
from dataclasses import dataclass, field
Expand Down
2 changes: 2 additions & 0 deletions application/engine/services/state_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
- 用户立即可见更新
- DB 作为持久化快照
"""
from __future__ import annotations

import logging
import time
from typing import Any, Dict, List, Optional
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

92 changes: 72 additions & 20 deletions frontend/src/components/autopilot/AutopilotDAGView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,53 @@ const gapSummary = computed(() =>
dagStore.registryGaps.map(g => `${g.node_id} (${g.node_type})`).join('、'),
)

/** 周期性拉权威 /status ,避免仅用 DAG Run SSE 把「人工审阅」误标成「运行中」 */
let autopilotStatusPollTimer: ReturnType<typeof setInterval> | null = null
/** 周期性拉权威 /status ,避免仅用 DAG Run SSE 把「人工审阅」误标成「运行中」。
* 非 running 时自动降速:paused → 15s,stopped/completed/error/idle → 60s。
* 页面切后台时暂停,切回时立即拉取一次再恢复。 */
let autopilotStatusPollTimer: ReturnType<typeof setTimeout> | null = null
/** 组件卸载后禁止再创建新 timer,防止僵尸轮询累积 */
let dagViewUnmounted = false
let autopilotFetchInFlight = false
let autopilotFetchAbort: AbortController | null = null
const AUTOPILOT_FETCH_TIMEOUT_MS = 10000

function autopilotPollInterval(): number {
switch (autopilotStatus.value) {
case 'running': return 7000
case 'paused': return 15000
default: return 60000
}
}

function clearAutopilotPoll() {
if (autopilotStatusPollTimer != null) {
clearTimeout(autopilotStatusPollTimer)
autopilotStatusPollTimer = null
}
if (autopilotFetchAbort) {
autopilotFetchAbort.abort()
autopilotFetchAbort = null
}
autopilotFetchInFlight = false
}

function scheduleAutopilotPoll() {
if (dagViewUnmounted) return
clearAutopilotPoll()
if (document.hidden) return
autopilotStatusPollTimer = window.setTimeout(() => {
void fetchAutopilotStatus()
}, autopilotPollInterval())
}

function handleDAGVisibilityChange() {
if (dagViewUnmounted) return
if (document.hidden) {
clearAutopilotPoll()
} else {
void fetchAutopilotStatus()
}
}

async function retryHydrate() {
await dagStore.hydrateDagForNovel(props.novelId)
Expand All @@ -121,22 +166,20 @@ onMounted(async () => {
await dagStore.hydrateDagForNovel(props.novelId)
await runStore.fetchStatus(props.novelId)
await fetchAutopilotStatus()
autopilotStatusPollTimer = window.setInterval(() => {
void fetchAutopilotStatus()
}, 7000)
document.addEventListener('visibilitychange', handleDAGVisibilityChange)
})

onUnmounted(() => {
if (autopilotStatusPollTimer != null) {
clearInterval(autopilotStatusPollTimer)
autopilotStatusPollTimer = null
}
dagViewUnmounted = true
clearAutopilotPoll()
document.removeEventListener('visibilitychange', handleDAGVisibilityChange)
})

// ★ 监听托管模式 SSE 日志:以 /status 为准合并「人工审阅」态
watch(
() => runStore.runStatus,
() => {
if (dagViewUnmounted) return
void fetchAutopilotStatus()
},
)
Expand Down Expand Up @@ -186,6 +229,17 @@ function handleSwitchToCard() {
// ─── 获取托管模式状态 ───

async function fetchAutopilotStatus() {
if (dagViewUnmounted) return
if (autopilotFetchInFlight) return
autopilotFetchInFlight = true

if (autopilotFetchAbort) {
autopilotFetchAbort.abort()
}
const ac = new AbortController()
autopilotFetchAbort = ac
const timeoutId = window.setTimeout(() => ac.abort(), AUTOPILOT_FETCH_TIMEOUT_MS)

try {
const { apiClient } = await import('@/api/config')
const result = await apiClient.get(`/autopilot/${props.novelId}/status`) as Record<string, unknown>
Expand All @@ -197,23 +251,21 @@ async function fetchAutopilotStatus() {

if (ap === 'completed') {
autopilotStatus.value = 'completed'
return
}
if (ap === 'error') {
} else if (ap === 'error') {
autopilotStatus.value = 'error'
return
}
if (ap === 'running' && humanGate) {
} else if (ap === 'running' && humanGate) {
autopilotStatus.value = 'paused'
return
}
if (ap === 'running') {
} else if (ap === 'running') {
autopilotStatus.value = 'running'
return
} else {
autopilotStatus.value = 'idle'
}
autopilotStatus.value = 'idle'
} catch {
autopilotStatus.value = 'idle'
} finally {
window.clearTimeout(timeoutId)
autopilotFetchInFlight = false
scheduleAutopilotPoll()
}
}
</script>
Expand Down
28 changes: 23 additions & 5 deletions frontend/src/components/autopilot/AutopilotPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ let statusLastAbort = null
/** 连续无法拉取 /status(网络拒绝/超时)时倍增轮询间隔 */
const statusConnectivityFailures = ref(0)
let lastStatusPollIntervalMs = -1
/** 组件卸载后禁止 maybeRestartStatusPollTimer 再创建 setInterval,防止僵尸轮询累积 */
let panelUnmounted = false

// 计算属性
const isRunning = computed(() => status.value?.autopilot_status === 'running')
Expand Down Expand Up @@ -680,6 +682,7 @@ function clearStatusPoll() {

/** 轮询间隔变化时(如后端断连退避)重置 timer,避免固定 3~5s 刷满 Vite 代理日志 */
function maybeRestartStatusPollTimer() {
if (panelUnmounted) return
if (statusPollDisabled.value) return
const ms = getAdaptivePollInterval()
if (statusPollTimer != null && ms === lastStatusPollIntervalMs) {
Expand Down Expand Up @@ -876,14 +879,28 @@ function stopChapterStream() {
// 策略:
// - SSE 已连接时:轮询降到 15s 兜底(SSE 已实时驱动刷新,轮询仅防断连漏检)
// - SSE 未连接但运行中:5s(需要轮询补偿 SSE 的缺失)
// - 非运行中:3s(用户可能刚操作,需要快速看到状态变化)
// - 审阅等待中:10s(用户在看大纲,不需要高频刷新)
// - stopped / completed:30s(无需高频,启动时 start() 会立即拉取恢复)
// - error:15s
// - 其他非运行中:3s(用户可能刚操作,需要快速看到状态变化)
function getAdaptivePollInterval() {
let base
if (needsReview.value) base = 10000
else if (!isRunning.value) base = 3000
else if (sseConnected.value) base = 15000
else base = 5000
if (needsReview.value) {
base = 10000
} else if (!isRunning.value) {
const ap = status.value?.autopilot_status
if (ap === 'stopped' || ap === 'completed') {
base = 30000
} else if (ap === 'error') {
base = 15000
} else {
base = 3000
}
} else if (sseConnected.value) {
base = 15000
} else {
base = 5000
}
const mult = Math.min(2 ** Math.min(statusConnectivityFailures.value, 8), 128)
return Math.min(base * mult, 120_000)
}
Expand Down Expand Up @@ -1183,6 +1200,7 @@ async function forceStopFromError() {

onMounted(() => { fetchStatus() })
onUnmounted(() => {
panelUnmounted = true
statusFetchSeq += 1
statusFetchInFlight = false // 🔥 重置请求去重标志
if (statusLastAbort) {
Expand Down
Loading