根因: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>
156 lines
5.6 KiB
Python
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)}
|