Files
my_bookmate/backend/app/api/books.py
T
gavindiazandClaude Code 64df54ad55 fix: 删书即删对话——清理已删除书籍遗留的孤儿对话
根因:Conversation 与 Book 无 ORM 级联关系,且 SQLite 外键未强制开启,
delete_book 只删书/章节/段落,书的对话变成孤儿残留(book_id 指向不存在的书),
前端确认文案「书、目录和对话都会被删除」与事实不符,AI 助手会把这些幽灵
对话当作阅读记录。

- books.py delete_book: 显式删除该书的全部对话(消息经 ORM cascade 一并删)
- database.py _migrate: 启动时清理孤儿对话及其消息(book_id 不在 books 表内;
  assistant 全局对话 book_id 为空不受影响),幂等,本地/服务器下次启动即生效

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-25 17:46:24 +08:00

156 lines
5.6 KiB
Python

import os
import uuid
from pathlib import Path
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 app.core.database import get_db, async_session
from app.core.config import settings
from app.models.book import Book, Chapter, Segment
from app.models.chat import Conversation
from app.schemas.book import BookOut, BookDetailOut, ChapterOut, SegmentOut
from app.epub.parser import parse_epub
from app.rag.vector import ensure_embeddings
router = APIRouter(prefix="/books", tags=["books"])
async def _embed_book_in_background(book_id: str):
"""上传后后台批量生成向量;失败时状态记 failed,首次对话会懒补。"""
async with async_session() as session:
try:
await ensure_embeddings(session, book_id)
except Exception:
pass
@router.post("", response_model=BookOut)
async def upload_book(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
background_tasks: BackgroundTasks = BackgroundTasks(),
):
if not file.filename or not file.filename.lower().endswith(".epub"):
raise HTTPException(400, "仅支持 .epub 文件")
upload_dir = settings.upload_path
ext = ".epub"
filename = f"{uuid.uuid4().hex}{ext}"
file_path = upload_dir / filename
async with aiofiles.open(file_path, "wb") as f:
content = await file.read()
await f.write(content)
try:
book = await parse_epub(str(file_path), db)
await db.commit()
await db.refresh(book)
background_tasks.add_task(_embed_book_in_background, book.id)
return book
except ValueError as e:
os.remove(file_path)
raise HTTPException(409, str(e))
except Exception as e:
os.remove(file_path)
raise HTTPException(500, f"解析失败: {str(e)}")
@router.get("", response_model=list[BookOut])
async def list_books(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Book).order_by(Book.created_at.desc()))
return result.scalars().all()
@router.get("/{book_id}", response_model=BookDetailOut)
async def get_book(book_id: str, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
ch_count = await db.scalar(
select(func.count()).select_from(Chapter).where(Chapter.book_id == book_id)
)
seg_count = await db.scalar(
select(func.count()).select_from(Segment).where(Segment.book_id == book_id)
)
return BookDetailOut(
id=book.id,
filename=book.filename,
title=book.title,
author=book.author,
language=book.language,
char_count=book.char_count,
status=book.status,
created_at=book.created_at,
chapter_count=ch_count or 0,
segment_count=seg_count or 0,
)
@router.get("/{book_id}/chapters", response_model=list[ChapterOut])
async def get_chapters(book_id: str, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
result = await db.execute(
select(Chapter).where(Chapter.book_id == book_id).order_by(Chapter.order_idx)
)
return result.scalars().all()
@router.get("/{book_id}/file")
async def read_book_file(book_id: str, db: AsyncSession = Depends(get_db)):
"""供浏览器阅读原始 EPUB,保留正文、图片和出版排版。"""
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():
raise HTTPException(404, "原始 EPUB 文件已丢失,请重新导入")
return FileResponse(
file_path,
media_type="application/epub+zip",
headers={"Cache-Control": "private, no-cache", "X-Content-Type-Options": "nosniff"},
)
@router.get("/{book_id}/segments", response_model=list[SegmentOut])
async def get_segments(book_id: str, chapter_id: str | None = None, db: AsyncSession = Depends(get_db)):
book = await db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
q = select(Segment).where(Segment.book_id == book_id)
if chapter_id:
q = q.where(Segment.chapter_id == chapter_id)
q = q.order_by(Segment.order_idx)
result = await db.execute(q)
return result.scalars().all()
@router.delete("/{book_id}")
async def delete_book(book_id: str, db: AsyncSession = Depends(get_db)):
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 一并删除。
convs = (await db.execute(
select(Conversation).where(Conversation.book_id == book_id)
)).scalars().all()
for c in convs:
await db.delete(c)
await db.delete(book)
await db.commit()
if file_path and os.path.exists(file_path):
os.remove(file_path)
return {"deleted": book_id, "conversations": len(convs)}