fix: 删书彻底清理文件与DB + 阅读位置跨设备同步

删除不彻底的根因是库里 file_path 存了旧 Windows 相对路径(uploads\xxx.epub),Linux 容器里 os.path.exists 永远为假:

- 新增 _upload_file() 归一化路径(取文件名挂到当前 uploads 目录),读/删都走它,老库迁移的文件也能定位;文件删除挪到后台任务,失败不影响接口

- 删除后对 SQLite 执行 VACUUM,让 db 文件真正缩小

- 删除时一并清理 reading_progress

阅读位置原来只存 localStorage,换设备丢失:

- 新增 reading_progress 表 + GET/PUT /books/{id}/progress 接口

- 前端打开时优先取服务端位置(本地兜底并双端拉齐),翻页防抖同步到服务端,关闭时把防抖窗口内的最后一页立即刷上去
This commit is contained in:
2026-10-27 21:42:04 +08:00
parent 5febc02099
commit 956323007d
6 changed files with 136 additions and 16 deletions
+82 -13
View File
@@ -5,19 +5,45 @@ import aiofiles
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select, func, delete
from app.core.database import get_db, async_session
from app.core.database import get_db, async_session, engine
from app.core.config import settings
from app.models.book import Book, Chapter, Segment, ReadingNote
from app.models.book import Book, Chapter, Segment, ReadingNote, ReadingProgress
from app.models.chat import Conversation
from app.schemas.book import BookOut, BookDetailOut, ChapterOut, SegmentOut, ReadingNoteCreate, ReadingNoteOut, ReadingNoteUpdate
from app.schemas.book import BookOut, BookDetailOut, ChapterOut, SegmentOut, ReadingNoteCreate, ReadingNoteOut, ReadingNoteUpdate, ReadingProgressOut, ReadingProgressUpdate
from app.epub.parser import parse_epub
from app.rag.vector import ensure_embeddings
router = APIRouter(prefix="/books", tags=["books"])
def _upload_file(file_path: str | None) -> Path | None:
"""把库里存的 file_path 归一化到 uploads 目录下的真实文件。
旧库曾存进相对 Windows 路径(uploads\\xxx.epub),在云服务器 Linux 上会
变成带反斜杠的「假文件名」,读不到也删不掉。统一取最后一段文件名,
挂到当前 upload_path 下,再校验存在性 —— 这样从 Windows 迁移的库也能删除。
"""
if not file_path:
return None
name = file_path.replace("\\", "/").split("/")[-1]
if not name or not name.lower().endswith(".epub"):
return None
candidate = settings.upload_path / name
return candidate if candidate.is_file() else None
async def _vacuum_db():
"""删书后压缩 SQLite 文件(DELETE 只打标,文件不缩水)。失败不影响主流程。"""
try:
async with engine.connect() as conn:
await conn.execution_options(isolation_level="AUTOCOMMIT")
await conn.exec_driver_sql("VACUUM")
except Exception:
pass
async def _embed_book_in_background(book_id: str):
"""上传后后台批量生成向量;失败时状态记 failed,首次对话会懒补。"""
async with async_session() as session:
@@ -108,11 +134,8 @@ async def read_book_file(book_id: str, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
file_path = Path(book.file_path).resolve()
upload_root = Path(settings.upload_dir).resolve()
if not file_path.is_relative_to(upload_root) or file_path.suffix.lower() != ".epub":
raise HTTPException(404, "书籍文件不可用")
if not file_path.is_file():
file_path = _upload_file(book.file_path)
if not file_path or not file_path.is_file():
raise HTTPException(404, "原始 EPUB 文件已丢失,请重新导入")
return FileResponse(
file_path,
@@ -195,12 +218,42 @@ async def delete_reading_note(book_id: str, note_id: str, db: AsyncSession = Dep
return {"deleted": note_id}
@router.get("/{book_id}/progress", response_model=ReadingProgressOut | None)
async def get_reading_progress(book_id: str, db: AsyncSession = Depends(get_db)):
"""读取每本书的最近阅读位置(跨设备同步用)。没记录返回 null。"""
if not await db.get(Book, book_id):
raise HTTPException(404, "书不存在")
progress = await db.get(ReadingProgress, book_id)
return progress
@router.put("/{book_id}/progress", response_model=ReadingProgressOut)
async def save_reading_progress(book_id: str, payload: ReadingProgressUpdate, db: AsyncSession = Depends(get_db)):
"""保存每本书的最近阅读位置(跨设备同步用)。"""
if not await db.get(Book, book_id):
raise HTTPException(404, "书不存在")
if not payload.cfi.strip() or len(payload.cfi) > 2000:
raise HTTPException(422, "阅读位置无效")
progress = await db.get(ReadingProgress, book_id)
if progress is None:
progress = ReadingProgress(book_id=book_id, cfi=payload.cfi.strip())
db.add(progress)
else:
progress.cfi = payload.cfi.strip()
await db.commit()
await db.refresh(progress)
return progress
@router.delete("/{book_id}")
async def delete_book(book_id: str, db: AsyncSession = Depends(get_db)):
async def delete_book(
book_id: str,
db: AsyncSession = Depends(get_db),
background_tasks: BackgroundTasks = BackgroundTasks(),
):
book = await db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
file_path = book.file_path
# 删书即删对话(与前端确认文案「书、目录和对话都会被删除」一致)。
# Conversation 与 Book 没有 ORM 级联关系、SQLite 外键又未强制开启,
# 必须显式删除该书的对话;其消息经 Conversation.messages 的 cascade 一并删除。
@@ -210,12 +263,28 @@ async def delete_book(book_id: str, db: AsyncSession = Depends(get_db)):
notes = (await db.execute(
select(ReadingNote).where(ReadingNote.book_id == book_id)
)).scalars().all()
# 先取出原始文件路径再删 ORM 对象(delete 后再访问属性可能触发已过期对象的加载)
file_path = _upload_file(book.file_path)
for c in convs:
await db.delete(c)
for note in notes:
await db.delete(note)
# 阅读位置随书清理
await db.execute(delete(ReadingProgress).where(ReadingProgress.book_id == book_id))
await db.delete(book)
await db.commit()
if file_path and os.path.exists(file_path):
os.remove(file_path)
# 原始 EPUB 文件在后台任务里删除:路径可能因旧库(Windows 相对路径)不直接可用,
# _upload_file 已归一化到当前 uploads 目录;失败不拖垮删除接口。
if file_path:
background_tasks.add_task(_remove_file, file_path)
# DELETE 后 SQLite 文件不会自动缩小,后台 VACUUM 一次让「db 数据减少」可见
background_tasks.add_task(_vacuum_db)
return {"deleted": book_id, "conversations": len(convs), "notes": len(notes)}
def _remove_file(path: Path):
"""后台任务:删除原始 EPUB。不抛出,避免拖垮删除流程。"""
try:
path.unlink(missing_ok=True)
except Exception:
pass
+1 -1
View File
@@ -16,7 +16,7 @@ from app.api.models import router as models_router
from app.api.mcp import router as mcp_router
from app.api.auth import router as auth_router
from app.models.book import Book, Chapter, Segment
from app.models.book import Book, Chapter, Segment, ReadingProgress
from app.models.chat import Conversation, Message
from app.models.ai_model import AiModel
from app.models.mcp_server import McpServer
+9
View File
@@ -74,3 +74,12 @@ class ReadingNote(Base):
color: Mapped[str] = mapped_column(String(20), nullable=False, default="yellow")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
class ReadingProgress(Base):
"""每本书的最近阅读位置(跨设备同步;书删除时一并清理)。"""
__tablename__ = "reading_progress"
book_id: Mapped[str] = mapped_column(String(36), ForeignKey("books.id", ondelete="CASCADE"), primary_key=True)
cfi: Mapped[str] = mapped_column(String(2000), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+13
View File
@@ -84,3 +84,16 @@ class ReadingNoteCreate(BaseModel):
class ReadingNoteUpdate(BaseModel):
note: str | None = None
color: str | None = None
class ReadingProgressOut(BaseModel):
book_id: str
cfi: str
updated_at: datetime
class Config:
from_attributes = True
class ReadingProgressUpdate(BaseModel):
cfi: str