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
|
||||
|
||||
@@ -184,7 +184,16 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
setToc(entries);
|
||||
setFontSize(initialFont);
|
||||
const host = viewerRef.current!;
|
||||
const savedCfi = parseSavedCfi(readSaved(positionKey));
|
||||
// 阅读位置优先取服务端(跨设备同步),本地 localStorage 只做离线兜底
|
||||
const localCfi = parseSavedCfi(readSaved(positionKey));
|
||||
let savedCfi = localCfi;
|
||||
try {
|
||||
const progress = await api.getReadingProgress(book.id);
|
||||
if (progress?.cfi) {
|
||||
savedCfi = progress.cfi;
|
||||
if (localCfi !== progress.cfi) save(positionKey, progress.cfi); // 双端拉齐
|
||||
}
|
||||
} catch { /* 拉取失败不阻断打开,用本地位置 */ }
|
||||
restoringPositionRef.current = true;
|
||||
cfiRef.current = savedCfi;
|
||||
setContinuous(false);
|
||||
@@ -255,7 +264,12 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
cfiRef.current = start.cfi;
|
||||
if (restoringPositionRef.current) return;
|
||||
window.clearTimeout(saveTimer);
|
||||
saveTimer = window.setTimeout(() => { if (!disposed) save(positionKey, start.cfi!); }, 300);
|
||||
saveTimer = window.setTimeout(() => {
|
||||
if (disposed) return;
|
||||
save(positionKey, start.cfi!);
|
||||
// 同步到服务端(跨设备);失败静默,下次 relocate 会再试
|
||||
void api.saveReadingProgress(book.id, start.cfi!).catch(() => {});
|
||||
}, 300);
|
||||
});
|
||||
rendition.on("selected", (cfi: string, contents: Contents) => {
|
||||
if (disposed) return;
|
||||
@@ -299,6 +313,11 @@ export default function EpubReader({ book, onClose, onAsk }: {
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
// 关书页时把防抖窗口里的位置立即刷到服务端,避免「最后一页丢失」
|
||||
if (cfiRef.current && !restoringPositionRef.current) {
|
||||
save(positionKey, cfiRef.current);
|
||||
void api.saveReadingProgress(book.id, cfiRef.current).catch(() => {});
|
||||
}
|
||||
window.clearTimeout(saveTimer);
|
||||
observer?.disconnect();
|
||||
renditionRef.current = null;
|
||||
|
||||
@@ -27,6 +27,12 @@ export interface ReadingNote {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ReadingProgress {
|
||||
book_id: string;
|
||||
cfi: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
book_id: string;
|
||||
@@ -161,6 +167,10 @@ export const api = {
|
||||
return res.json() as Promise<Book>;
|
||||
},
|
||||
deleteBook: (id: string) => request<{ deleted: string }>(`/books/${id}`, { method: "DELETE" }),
|
||||
// 阅读位置(跨设备同步):服务端存最近位置,本地 localStorage 只做离线兜底
|
||||
getReadingProgress: (bookId: string) => request<ReadingProgress | null>(`/books/${bookId}/progress`),
|
||||
saveReadingProgress: (bookId: string, cfi: string) =>
|
||||
request<ReadingProgress>(`/books/${bookId}/progress`, { method: "PUT", body: JSON.stringify({ cfi }) }),
|
||||
|
||||
// Conversations
|
||||
listConversations: (bookId?: string, kind?: string) => {
|
||||
|
||||
Reference in New Issue
Block a user