Files
my_bookmate/backend/app/api/books.py
T

147 lines
5.0 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.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
await db.delete(book)
await db.commit()
if file_path and os.path.exists(file_path):
os.remove(file_path)
return {"deleted": book_id}