feat: 清理删除书籍遗留的孤儿数据(DB 行 + uploads 文件)

历史版本删书时因 file_path 存 Windows 相对路径 + SQLite 外键未强制开启,
会残留无主 DB 行和 uploads 下的孤儿 EPUB 文件。

- 新增 app/scripts/clean_orphans.py:只删 book_id 指向不存在书籍的行
  (segments/chapters/reading_notes/reading_progress/conversations/messages)
  和 uploads 下无书籍记录的 *.epub 文件,现存的书籍/笔记/对话/模型/MCP
  一律不动;幂等可重跑,缺新表自动跳过,清完 VACUUM 缩库
- 启动迁移(_migrate)同样按外键清理孤儿行并探测表存在性,旧库缺表不崩

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-27 22:14:01 +08:00
co-authored by Claude Code
parent 956323007d
commit 3e8b21f99c
2 changed files with 136 additions and 0 deletions
+13
View File
@@ -64,6 +64,19 @@ async def _migrate():
"DELETE FROM messages WHERE conversation_id NOT IN (SELECT id FROM conversations)"
)
# 删书不彻底的历史遗留:章节/分片/摘录/阅读位置也可能残留孤儿行。
# 上传阶段没有这些表的行(它们随 parse_epub 创建),此处只清「book_id 已不存在」的行。
# 注意旧库可能还没有 reading_progress 等新表,先探测再删,缺表跳过。
table_rows = (await conn.exec_driver_sql(
"SELECT name FROM sqlite_master WHERE type='table'"
)).all()
existing_tables = {row[0] for row in table_rows}
for table in ("segments", "chapters", "reading_notes", "reading_progress"):
if table in existing_tables:
await conn.exec_driver_sql(
f"DELETE FROM {table} WHERE book_id NOT IN (SELECT id FROM books)"
)
async def get_db():
async with async_session() as session:
+123
View File
@@ -0,0 +1,123 @@
"""清除残留的孤儿数据(一次性维护脚本,幂等可重跑)。
背景:早期删除书籍时,因 file_path 存了 Windows 相对路径 + SQLite 外键未强制开启,
会残留「无主」的 DB 行(reading_notes / reading_progress / segments / chapters /
conversations / messages)和 uploads 目录下无书籍记录的原始 EPUB 文件。
本脚本只删除无主数据,不触碰任何现存书籍 / 笔记 / 对话(kind='assistant' 的全局
对话 book_id 为 NULL,天然不受影响)。清完执行一次 VACUUM 缩库。
用法(在服务器 backend 目录,用与 uvicorn 相同的 Python 环境):
python -m app.scripts.clean_orphans
"""
import asyncio
import os
import sys
import traceback
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from sqlalchemy import text # noqa: E402
from app.core.config import settings # noqa: E402
from app.core.database import engine, async_session # noqa: E402
def _normalize_stored_path(stored: str | None) -> str | None:
"""把库里 file_path(可能是 Windows 反斜杠相对路径)归一化为「文件名」。"""
if not stored:
return None
name = stored.replace("\\", "/").split("/")[-1]
return name or None
async def _existing_tables(db) -> set[str]:
rows = (await db.execute(
text("SELECT name FROM sqlite_master WHERE type='table'")
)).scalars().all()
return set(rows)
async def clean_db_rows() -> dict[str, int]:
"""删除所有外键指向不存在书籍/会话的孤儿行。返回 {表: 删了多少}。
只处理实际存在的表(旧库可能还没有 reading_progress 等新表),
每条语句各自提交失败对应的表也不影响其它清理。
"""
counts: dict[str, int] = {}
async with async_session() as db:
existing = await _existing_tables(db)
for table, cond in [
("reading_progress", "book_id NOT IN (SELECT id FROM books)"),
("reading_notes", "book_id NOT IN (SELECT id FROM books)"),
("segments", "book_id NOT IN (SELECT id FROM books)"),
("chapters", "book_id NOT IN (SELECT id FROM books)"),
# book_id 为 NULL 的是 assistant 全局对话,排除
("conversations", "book_id IS NOT NULL AND book_id NOT IN (SELECT id FROM books)"),
("messages", "conversation_id NOT IN (SELECT id FROM conversations)"),
]:
if table not in existing:
counts[table] = -1 # 表不存在,跳过
continue
result = await db.execute(text(f"DELETE FROM {table} WHERE {cond}"))
counts[table] = result.rowcount or 0
await db.commit()
return counts
async def clean_orphan_files() -> list[str]:
"""删除 uploads 下没有书籍记录的 *.epub 文件。返回被删的文件名列表。"""
try:
from app.models.book import Book # noqa: F401 (注册模型元数据)
from sqlalchemy import select
except Exception:
pass
async with async_session() as db:
rows = (await db.execute(text("SELECT file_path FROM books"))).scalars().all()
known = {_normalize_stored_path(p) for p in rows}
known.discard(None)
upload_dir = settings.upload_path
removed: list[str] = []
if not upload_dir.is_dir():
return removed
for entry in upload_dir.iterdir():
if not entry.is_file() or entry.suffix.lower() != ".epub":
continue
if entry.name not in known:
try:
entry.unlink()
removed.append(entry.name)
except OSError:
pass # 占用/权限问题跳过,不影响其它清理
return removed
async def vacuum() -> None:
try:
async with engine.connect() as conn:
await conn.execution_options(isolation_level="AUTOCOMMIT")
await conn.exec_driver_sql("VACUUM")
except Exception:
traceback.print_exc()
async def main() -> int:
print("== 清理孤儿数据库行 ==")
try:
counts = await clean_db_rows()
except Exception:
traceback.print_exc()
return 1
for table, n in counts.items():
print(f" - {table}: " + ("表不存在,跳过" if n == -1 else f"删除 {n} 行"))
print("== 清理 uploads 孤儿 EPUB 文件 ==")
removed = await clean_orphan_files()
print(f" - 删除文件 {len(removed)} 个: {', '.join(removed[:10]) or '无'}")
print("== VACUUM 压缩数据库 ==")
await vacuum()
print("完成。现存书籍、笔记、对话均未改动。")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))