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