nex_math/backend/services/ebooks.py

290 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""电子书存储:文件放 data/ebooks/{textbook_id}/ 下,数据库只存文件名与元信息。
上传按分块写临时文件(不整体进内存),先校验扩展名与文件头魔数,
再原子改名,避免半截文件污染在线阅读。
"""
from __future__ import annotations
import hashlib
import os
import re
import shutil
import uuid
import zipfile
from datetime import datetime
from pathlib import Path
from xml.etree import ElementTree
from fastapi import HTTPException, UploadFile
from sqlalchemy.orm import Session
from pypdf import PdfReader
from database import REPO_ROOT
from models import EbookProgress, Textbook
EBOOK_ROOT = REPO_ROOT / "data" / "ebooks"
MAX_EBOOK_BYTES = 500 * 1024 * 1024 # 500MB
_CHUNK = 1024 * 1024
# 扩展名 -> (格式标识, 魔数, MIME)
KINDS: dict[str, tuple[str, bytes]] = {
".pdf": ("pdf", b"%PDF-"),
".epub": ("epub", b"PK\x03\x04"),
}
MIME_BY_FORMAT = {"pdf": "application/pdf", "epub": "application/epub+zip"}
def book_dir(textbook_id: int) -> Path:
return EBOOK_ROOT / str(textbook_id)
def resolve_path(textbook_id: int, filename: str) -> Path:
"""只允许该书目录下的裸文件名,杜绝 ../ 穿越。"""
safe = os.path.basename(filename)
if not safe or safe.startswith("."):
raise HTTPException(status_code=404, detail="电子书文件不存在")
path = (book_dir(textbook_id) / safe).resolve()
root = book_dir(textbook_id).resolve()
if path.parent != root or not path.is_file():
raise HTTPException(status_code=404, detail="电子书文件不存在")
return path
def media_type(fmt: str) -> str:
return MIME_BY_FORMAT.get(fmt, "application/octet-stream")
def file_kind(filename: str) -> tuple[str, str]:
"""按扩展名给出 (格式, 魔数);不支持的扩展名直接拒绝。"""
ext = Path(filename or "").suffix.lower()
kind = KINDS.get(ext)
if kind is None:
raise HTTPException(status_code=400, detail="只支持 PDF 与 EPUB 两种格式")
return kind[0], kind[1]
def _safe_name(filename: str) -> str:
"""原始文件名只用于展示:去掉路径与可能破坏排版的控制字符。"""
name = os.path.basename(filename or "").strip()
name = re.sub(r"[\r\n\t]+", " ", name)
return name[:180]
async def store_upload(db: Session, textbook: Textbook, upload: UploadFile) -> Textbook:
fmt, magic = file_kind(upload.filename or "")
target_dir = book_dir(textbook.id)
target_dir.mkdir(parents=True, exist_ok=True)
temp = target_dir / f".upload-{uuid.uuid4().hex}.part"
size = 0
try:
with temp.open("wb") as handle:
while True:
chunk = await upload.read(_CHUNK)
if not chunk:
break
if size == 0 and not chunk.startswith(magic):
raise HTTPException(
status_code=400, detail="文件内容与扩展名不符,请上传真实的 PDF / EPUB 文件"
)
size += len(chunk)
if size > MAX_EBOOK_BYTES:
raise HTTPException(status_code=400, detail="电子书超过 500MB 上限")
handle.write(chunk)
if size == 0:
raise HTTPException(status_code=400, detail="上传的文件是空的")
final = target_dir / f"{uuid.uuid4().hex}.{fmt}"
os.replace(temp, final)
finally:
if temp.exists():
temp.unlink(missing_ok=True)
await upload.close()
# 覆盖式上传:旧文件随后清理,磁盘上不留重复副本
if textbook.ebook_file:
try:
resolve_path(textbook.id, textbook.ebook_file).unlink(missing_ok=True)
except HTTPException:
pass
textbook.ebook_file = final.name
textbook.ebook_format = fmt
textbook.ebook_name = _safe_name(upload.filename or final.name)
textbook.ebook_size = size
textbook.ebook_pages = count_pages(final, fmt)
textbook.ebook_uploaded_at = datetime.utcnow()
db.commit()
return textbook
def clear_ebook(db: Session, textbook: Textbook) -> Textbook:
if textbook.ebook_file:
try:
resolve_path(textbook.id, textbook.ebook_file).unlink(missing_ok=True)
except HTTPException:
pass
textbook.ebook_file = ""
textbook.ebook_name = ""
textbook.ebook_format = ""
textbook.ebook_size = 0
textbook.ebook_pages = 0
textbook.ebook_uploaded_at = None
db.commit()
return textbook
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as reader:
for chunk in iter(lambda: reader.read(_CHUNK), b""):
digest.update(chunk)
return digest.hexdigest()
def find_identical(textbook_id: int, source: Path) -> Path | None:
"""该书目录下是否已经有一模一样的文件(按大小 + SHA-256 判定)。"""
target_dir = book_dir(textbook_id)
if not target_dir.is_dir() or not source.is_file():
return None
size = source.stat().st_size
digest = sha256_file(source)
for candidate in sorted(target_dir.iterdir()):
if not candidate.is_file() or candidate.stat().st_size != size:
continue
if sha256_file(candidate) == digest:
return candidate
return None
def adopt_file(
db: Session,
textbook: Textbook,
filename: str,
display_name: str = "",
) -> Textbook:
"""登记该书目录下已经在磁盘上的文件(认领孤儿电子书,不重新复制)。"""
path = resolve_path(textbook.id, filename)
fmt, _ = file_kind(path.name)
textbook.ebook_file = path.name
textbook.ebook_format = fmt
textbook.ebook_name = _safe_name(display_name or path.name)
textbook.ebook_size = path.stat().st_size
textbook.ebook_pages = count_pages(path, fmt)
textbook.ebook_uploaded_at = datetime.utcnow()
db.commit()
return textbook
def attach_local_file(
db: Session,
textbook: Textbook,
source: Path,
display_name: str = "",
) -> Textbook:
"""给种子脚本用:把仓库里的电子书复制进 data/ebooks/{id}/ 并登记。"""
fmt, magic = file_kind(source.name)
if not source.is_file():
raise FileNotFoundError(str(source))
target_dir = book_dir(textbook.id)
target_dir.mkdir(parents=True, exist_ok=True)
# 同一本书重复导入(例如换库重跑种子)时复用已有文件,避免多占几十 MB
identical = find_identical(textbook.id, source)
if identical is not None:
return adopt_file(db, textbook, identical.name, display_name=display_name)
final = target_dir / f"{uuid.uuid4().hex}.{fmt}"
with source.open("rb") as reader, final.open("wb") as writer:
head = reader.read(len(magic))
if not head.startswith(magic):
final.unlink(missing_ok=True)
raise ValueError(f"{source.name} 不是有效的 {fmt.upper()} 文件")
writer.write(head)
shutil.copyfileobj(reader, writer, _CHUNK)
if textbook.ebook_file:
try:
resolve_path(textbook.id, textbook.ebook_file).unlink(missing_ok=True)
except HTTPException:
pass
textbook.ebook_file = final.name
textbook.ebook_format = fmt
textbook.ebook_name = _safe_name(display_name or source.name)
textbook.ebook_size = final.stat().st_size
textbook.ebook_pages = count_pages(final, fmt)
textbook.ebook_uploaded_at = datetime.utcnow()
db.commit()
return textbook
def _pdf_pages(path: Path) -> int:
return len(PdfReader(path).pages)
def _epub_pages(path: Path) -> int:
"""EPUB 没有“页”,用正文文档数(spine 条目)作为篇幅。"""
with zipfile.ZipFile(path) as archive:
container = ElementTree.fromstring(archive.read("META-INF/container.xml"))
opf = next(
(
node.get("full-path")
for node in container.iter()
if node.tag.endswith("rootfile")
),
"",
)
if not opf:
return 0
package = ElementTree.fromstring(archive.read(opf))
spine = next((node for node in package if node.tag.endswith("spine")), None)
if spine is None:
return 0
return len(
[
item
for item in spine
if item.tag.endswith("itemref") and item.get("idref")
]
)
def count_pages(path: Path, fmt: str) -> int:
"""解析篇幅;文件损坏时返回 0,交给阅读器再回填,不阻断上传。"""
try:
return _pdf_pages(path) if fmt == "pdf" else _epub_pages(path)
except Exception: # noqa: BLE001 - 解析器版本/畸形文件都按“未知篇幅”处理
return 0
def backfill_pages(db: Session) -> None:
"""给存量数据补篇幅:上传时没解析过(或当时解析失败)的书。"""
rows = (
db.query(Textbook)
.filter(Textbook.ebook_file != "", Textbook.ebook_pages == 0)
.all()
)
changed = False
for textbook in rows:
try:
path = resolve_path(textbook.id, textbook.ebook_file)
except HTTPException:
continue
pages = count_pages(path, textbook.ebook_format)
if pages and textbook.ebook_pages != pages:
textbook.ebook_pages = pages
changed = True
if textbook.ebook_uploaded_at is None:
textbook.ebook_uploaded_at = datetime.utcfromtimestamp(
path.stat().st_mtime
)
changed = True
if changed:
db.commit()
def drop_book_files(textbook_id: int) -> None:
shutil.rmtree(book_dir(textbook_id), ignore_errors=True)
def drop_progress(db: Session, textbook_id: int) -> None:
db.query(EbookProgress).filter(EbookProgress.textbook_id == textbook_id).delete()