hku-class/backend/app/services/reading_service.py
2026-05-01 21:52:27 +08:00

485 lines
17 KiB
Python

import asyncio
import logging
from datetime import date, datetime
import httpx
from sqlalchemy import case, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.db.database import async_session
from app.db.models import ClassMembership, ReadingBook, ReadingNote, User
from app.schemas.reading import ReadingBookCreate, ReadingBookUpdate, ReadingNoteCreate, ReadingNoteUpdate
from app.services.cos_service import upload_image
logger = logging.getLogger(__name__)
GOOGLE_BOOKS_API = "https://www.googleapis.com/books/v1/volumes"
GOOGLE_BOOKS_HEADERS = {"User-Agent": "HKU-ICB-ClassHub/1.0"}
def _month_start() -> datetime:
today = date.today()
return datetime(today.year, today.month, 1)
def validate_book_pages(total_pages: int, current_page: int):
if total_pages < 0:
raise ValueError("Total pages cannot be negative")
if current_page < 0:
raise ValueError("Current page cannot be negative")
if total_pages and current_page > total_pages:
raise ValueError("Current page cannot exceed total pages")
async def create_book(
db: AsyncSession, class_id: int, owner_id: int, data: ReadingBookCreate
) -> ReadingBook:
validate_book_pages(data.total_pages, data.current_page)
book = ReadingBook(class_id=class_id, owner_id=owner_id, **data.model_dump())
db.add(book)
await db.commit()
await db.refresh(book)
return book
async def update_book(db: AsyncSession, book: ReadingBook, data: ReadingBookUpdate) -> ReadingBook:
values = data.model_dump(exclude_unset=True)
total_pages = values.get("total_pages", book.total_pages)
current_page = values.get("current_page", book.current_page)
if "current_page" in values and current_page < book.current_page:
raise ValueError("Current page can only increase")
validate_book_pages(total_pages, current_page)
for field, value in values.items():
setattr(book, field, value)
await db.commit()
await db.refresh(book)
return book
def schedule_cover_fetch(book_id: int):
asyncio.create_task(_safe_fetch_and_store_cover(book_id))
async def _safe_fetch_and_store_cover(book_id: int):
try:
await fetch_and_store_cover(book_id)
except Exception as exc:
logger.warning("Failed to fetch reading book cover for %s: %s", book_id, exc)
async def fetch_and_store_cover(book_id: int):
if not (settings.cos_bucket and settings.cos_base_url):
logger.info("Skip reading cover fetch because COS is not configured")
return
async with async_session() as db:
book = await get_book_by_id(db, book_id)
if book is None or book.cover_url:
return
source_url = await find_cover_url(book.title, book.author)
if not source_url:
return
data, content_type = await download_cover(source_url)
cos_url = await asyncio.to_thread(
upload_image,
f"reading-covers/{book.class_id}/{book.id}",
"cover.jpg",
data,
content_type,
)
await db.execute(
update(ReadingBook)
.where(
ReadingBook.id == book.id,
or_(ReadingBook.cover_url == None, ReadingBook.cover_url == ""),
)
.values(cover_url=cos_url)
)
await db.commit()
async def find_cover_url(title: str, author: str | None = None) -> str | None:
query_parts = [f"intitle:{title}"]
if author:
query_parts.append(f"inauthor:{author}")
async with httpx.AsyncClient(timeout=12, follow_redirects=True) as client:
params = {
"q": " ".join(query_parts),
"maxResults": "5",
"printType": "books",
}
if settings.google_books_api_key:
params["key"] = settings.google_books_api_key
try:
response = await client.get(
GOOGLE_BOOKS_API,
params=params,
headers=GOOGLE_BOOKS_HEADERS,
)
response.raise_for_status()
except httpx.HTTPError as exc:
logger.info("Google Books cover lookup failed for %s: %s", title, exc)
return None
data = response.json()
for item in data.get("items", []):
image_links = item.get("volumeInfo", {}).get("imageLinks", {})
cover_url = image_links.get("thumbnail") or image_links.get("smallThumbnail")
if cover_url:
return str(cover_url).replace("http://", "https://")
return None
async def download_cover(url: str) -> tuple[bytes, str]:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(url, headers=GOOGLE_BOOKS_HEADERS)
response.raise_for_status()
content_type = response.headers.get("content-type", "image/jpeg").split(";")[0]
if content_type not in {"image/jpeg", "image/png", "image/gif", "image/webp"}:
content_type = "image/jpeg"
return response.content, content_type
async def delete_book(db: AsyncSession, book: ReadingBook):
await db.delete(book)
await db.commit()
async def get_book_by_id(db: AsyncSession, book_id: int) -> ReadingBook | None:
result = await db.execute(
select(ReadingBook)
.options(
selectinload(ReadingBook.owner),
selectinload(ReadingBook.notes),
)
.where(ReadingBook.id == book_id)
)
return result.scalar_one_or_none()
async def list_books(
db: AsyncSession,
class_id: int,
page: int = 1,
page_size: int = 20,
status: str | None = None,
owner_id: int | None = None,
search: str | None = None,
) -> tuple[list[ReadingBook], int]:
filters = [ReadingBook.class_id == class_id]
if status and status != "all":
filters.append(ReadingBook.status == status)
if owner_id:
filters.append(ReadingBook.owner_id == owner_id)
if search:
pattern = f"%{search}%"
filters.append(or_(ReadingBook.title.ilike(pattern), ReadingBook.author.ilike(pattern)))
count_result = await db.execute(select(func.count(ReadingBook.id)).where(*filters))
total = count_result.scalar() or 0
result = await db.execute(
select(ReadingBook)
.options(
selectinload(ReadingBook.owner),
selectinload(ReadingBook.notes),
)
.where(*filters)
.order_by(ReadingBook.updated_at.desc(), ReadingBook.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
async def create_note(
db: AsyncSession, book: ReadingBook, author_id: int, data: ReadingNoteCreate
) -> ReadingNote:
note = ReadingNote(book_id=book.id, author_id=author_id, **data.model_dump())
db.add(note)
await db.commit()
await db.refresh(note)
return note
async def update_note(db: AsyncSession, note: ReadingNote, data: ReadingNoteUpdate) -> ReadingNote:
for field, value in data.model_dump(exclude_unset=True).items():
setattr(note, field, value)
await db.commit()
await db.refresh(note)
return note
async def delete_note(db: AsyncSession, note: ReadingNote):
await db.delete(note)
await db.commit()
async def get_note_by_id(db: AsyncSession, note_id: int) -> ReadingNote | None:
result = await db.execute(
select(ReadingNote)
.options(
selectinload(ReadingNote.author),
selectinload(ReadingNote.book).selectinload(ReadingBook.owner),
)
.where(ReadingNote.id == note_id)
)
return result.scalar_one_or_none()
async def list_notes(
db: AsyncSession,
class_id: int,
viewer_id: int,
page: int = 1,
page_size: int = 20,
book_id: int | None = None,
author_id: int | None = None,
include_private: bool = False,
search: str | None = None,
) -> tuple[list[ReadingNote], int]:
filters = [ReadingBook.class_id == class_id]
if include_private:
filters.append(or_(ReadingNote.visibility == "class", ReadingNote.author_id == viewer_id))
else:
filters.append(ReadingNote.visibility == "class")
if book_id:
filters.append(ReadingNote.book_id == book_id)
if author_id:
filters.append(ReadingNote.author_id == author_id)
if search:
pattern = f"%{search}%"
filters.append(or_(ReadingBook.title.ilike(pattern), ReadingNote.title.ilike(pattern)))
base = select(ReadingNote).join(ReadingBook).where(*filters)
count_result = await db.execute(
select(func.count(ReadingNote.id)).join(ReadingBook).where(*filters)
)
total = count_result.scalar() or 0
result = await db.execute(
base.options(
selectinload(ReadingNote.author),
selectinload(ReadingNote.book),
)
.order_by(ReadingNote.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total
async def get_summary(db: AsyncSession, class_id: int, user_id: int) -> dict:
books_result = await db.execute(
select(
func.coalesce(func.sum(ReadingBook.current_page), 0),
func.sum(case((ReadingBook.status == "reading", 1), else_=0)),
func.sum(case((ReadingBook.status == "finished", 1), else_=0)),
).where(ReadingBook.class_id == class_id, ReadingBook.owner_id == user_id)
)
total_pages, reading_count, finished_count = books_result.one()
score_rows = await get_ranking_rows(db, class_id, period="month", user_id=user_id)
month_score = score_rows[0]["score"] if score_rows else 0
return {
"reading_count": int(reading_count or 0),
"finished_count": int(finished_count or 0),
"total_pages_read": int(total_pages or 0),
"month_score": int(month_score or 0),
}
def _book_progress(book: ReadingBook) -> int:
if not book.total_pages:
return 100 if book.current_page > 0 else 0
return min(100, round((book.current_page / book.total_pages) * 100))
def _note_excerpt(content: str, limit: int = 140) -> str:
normalized = " ".join(content.split())
return normalized if len(normalized) <= limit else f"{normalized[:limit]}..."
async def list_feed_items(
db: AsyncSession, class_id: int, page: int = 1, page_size: int = 20
) -> tuple[list[dict], int]:
book_result = await db.execute(
select(ReadingBook)
.options(selectinload(ReadingBook.owner))
.where(ReadingBook.class_id == class_id)
.order_by(ReadingBook.updated_at.desc())
)
note_result = await db.execute(
select(ReadingNote)
.join(ReadingBook)
.options(
selectinload(ReadingNote.author),
selectinload(ReadingNote.book),
)
.where(
ReadingBook.class_id == class_id,
ReadingNote.visibility == "class",
)
.order_by(ReadingNote.created_at.desc())
)
items: list[dict] = []
for book in book_result.scalars().all():
event_type = "finished" if book.status == "finished" else "progress"
items.append(
{
"id": f"{event_type}:{book.id}",
"type": event_type,
"class_id": book.class_id,
"user_id": book.owner_id,
"user_name": book.owner.name if book.owner else "Unknown",
"book_id": book.id,
"book_title": book.title,
"book_author": book.author,
"book_cover_url": book.cover_url,
"status": book.status,
"current_page": book.current_page,
"total_pages": book.total_pages,
"progress": _book_progress(book),
"note_id": None,
"note_title": None,
"note_excerpt": None,
"page_ref": None,
"created_at": book.updated_at,
}
)
for note in note_result.scalars().all():
book = note.book
items.append(
{
"id": f"note:{note.id}",
"type": "note",
"class_id": book.class_id,
"user_id": note.author_id,
"user_name": note.author.name if note.author else "Unknown",
"book_id": note.book_id,
"book_title": book.title,
"book_author": book.author,
"book_cover_url": book.cover_url,
"status": book.status,
"current_page": book.current_page,
"total_pages": book.total_pages,
"progress": _book_progress(book),
"note_id": note.id,
"note_title": note.title,
"note_excerpt": _note_excerpt(note.content),
"page_ref": note.page_ref,
"created_at": note.created_at,
}
)
items.sort(key=lambda item: item["created_at"], reverse=True)
total = len(items)
start = (page - 1) * page_size
end = start + page_size
return items[start:end], total
async def get_ranking_rows(
db: AsyncSession, class_id: int, period: str = "month", user_id: int | None = None
) -> list[dict]:
book_filters = [ReadingBook.class_id == class_id]
note_filters = [ReadingBook.class_id == class_id]
if period == "month":
since = _month_start()
book_filters.append(ReadingBook.updated_at >= since)
note_filters.append(ReadingNote.created_at >= since)
if user_id:
book_filters.append(ReadingBook.owner_id == user_id)
note_filters.append(ReadingNote.author_id == user_id)
book_result = await db.execute(
select(
ReadingBook.owner_id.label("user_id"),
func.coalesce(func.sum(ReadingBook.current_page), 0).label("pages_read"),
func.sum(case((ReadingBook.status == "finished", 1), else_=0)).label("finished_books"),
)
.where(*book_filters)
.group_by(ReadingBook.owner_id)
)
note_result = await db.execute(
select(
ReadingNote.author_id.label("user_id"),
func.sum(case((ReadingNote.visibility == "class", 1), else_=0)).label("public_notes"),
func.sum(case((ReadingNote.visibility == "private", 1), else_=0)).label("private_notes"),
)
.join(ReadingBook)
.where(*note_filters)
.group_by(ReadingNote.author_id)
)
member_result = await db.execute(
select(User.id, User.name)
.join(ClassMembership, ClassMembership.user_id == User.id)
.where(ClassMembership.class_id == class_id, User.status == "approved")
)
rows: dict[int, dict] = {}
for member_id, member_name in member_result.all():
if user_id and member_id != user_id:
continue
rows[member_id] = {
"user_id": member_id,
"user_name": member_name,
"pages_read": 0,
"finished_books": 0,
"public_notes": 0,
"private_notes": 0,
}
for row in book_result.mappings():
item = rows.setdefault(row["user_id"], {
"user_id": row["user_id"],
"user_name": "Unknown",
"pages_read": 0,
"finished_books": 0,
"public_notes": 0,
"private_notes": 0,
})
item["pages_read"] = int(row["pages_read"] or 0)
item["finished_books"] = int(row["finished_books"] or 0)
for row in note_result.mappings():
item = rows.setdefault(row["user_id"], {
"user_id": row["user_id"],
"user_name": "Unknown",
"pages_read": 0,
"finished_books": 0,
"public_notes": 0,
"private_notes": 0,
})
item["public_notes"] = int(row["public_notes"] or 0)
item["private_notes"] = int(row["private_notes"] or 0)
missing_name_ids = [
item["user_id"] for item in rows.values() if item["user_name"] == "Unknown"
]
if missing_name_ids:
user_result = await db.execute(
select(User.id, User.name).where(User.id.in_(missing_name_ids))
)
name_by_id = {user_id: name for user_id, name in user_result.all()}
for item in rows.values():
if item["user_name"] == "Unknown":
item["user_name"] = name_by_id.get(item["user_id"], f"用户{item['user_id']}")
for item in rows.values():
item["score"] = (
item["pages_read"]
+ item["finished_books"] * 50
+ item["public_notes"] * 20
+ item["private_notes"] * 10
)
return sorted(rows.values(), key=lambda x: (-x["score"], -x["pages_read"], x["user_name"]))