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:
@@ -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:
|
||||
|
||||
@@ -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()))
|
||||
Reference in New Issue
Block a user