"""教辅 (book) 业务层 — MongoDB 版本 幂等策略: - 同 zip_sha256 直接 return 旧 book - 同 (name, version, is_deleted) 唯一索引保护 """ import re from pymongo.errors import DuplicateKeyError from db import get_collection, COL_BOOK, COL_DOC, COL_FILE, next_id, Book, BookDocument, DocumentFile from db.models import _now def create_book(name: str, zip_path: str, zip_sha256: str, zip_size: int = 0, subject: str | None = None, grade: str | None = None, version: str | None = None, type_: str | None = None, created_by: str | None = None) -> dict: """创建教辅。同 zip_sha256 幂等。""" col = get_collection(COL_BOOK) # 幂等查 existing = col.find_one({'zip_sha256': zip_sha256, 'is_deleted': 0}) if existing: b = Book.from_mongo(existing) return { 'ok': True, 'idempotent': True, 'book_id': b.id, 'status': b.status, 'total_count': b.total_count, } # 自增 book_id book_id = next_id('book_id') book = Book( id=book_id, name=name, zip_path=zip_path, zip_sha256=zip_sha256, zip_size=zip_size, subject=subject, grade=grade, version=version, type=type_, created_by=created_by, status='pending', ) try: col.insert_one(book.to_mongo()) except DuplicateKeyError: # uk_name_version 撞了,重查 existing = col.find_one({'zip_sha256': zip_sha256, 'is_deleted': 0}) if existing: b = Book.from_mongo(existing) return { 'ok': True, 'idempotent': True, 'book_id': b.id, 'status': b.status, 'total_count': b.total_count, } return {'ok': False, 'error': 'duplicate key (name+version)'} return { 'ok': True, 'idempotent': False, 'book_id': book_id, 'status': 'pending', 'total_count': 0, } def get_book(book_id: int, include_documents: bool = True) -> dict | None: """查教辅(含所有 doc + file)""" col = get_collection(COL_BOOK) doc = col.find_one({'_id': book_id, 'is_deleted': 0}) if not doc: return None book = Book.from_mongo(doc).to_dict() if include_documents: docs = list(get_collection(COL_DOC).find({'book_id': book_id}) .sort('order_index', 1)) book['documents'] = [] for d in docs: bd = BookDocument.from_mongo(d).to_dict() files = list(get_collection(COL_FILE).find({'doc_id': bd['doc_id']})) bd['files'] = [DocumentFile.from_mongo(f).to_dict() for f in files] book['documents'].append(bd) return book def list_books(status: str | None = None, page: int = 1, page_size: int = 20, keyword: str | None = None) -> dict: """列表查询(分页)""" col = get_collection(COL_BOOK) q: dict = {'is_deleted': 0} if status: q['status'] = status if keyword: kw = re.escape(keyword) q['$or'] = [ {'name': {'$regex': kw, '$options': 'i'}}, {'subject': {'$regex': kw, '$options': 'i'}}, {'grade': {'$regex': kw, '$options': 'i'}}, {'version': {'$regex': kw, '$options': 'i'}}, ] total = col.count_documents(q) cursor = (col.find(q).sort('created_at', -1) .skip((page - 1) * page_size).limit(page_size)) books = [Book.from_mongo(d).to_dict() for d in cursor] return {'books': books, 'total': total, 'page': page, 'page_size': page_size} def update_book_status(book_id: int, status: str, error_msg: str | None = None) -> bool: col = get_collection(COL_BOOK) upd = {'status': status, 'updated_at': _now()} if error_msg is not None: upd['error_msg'] = error_msg r = col.update_one({'_id': book_id, 'is_deleted': 0}, {'$set': upd}) return r.matched_count > 0 def recompute_book_stats(book_id: int) -> None: """重算 total / success / failed,并触发状态机""" col_book = get_collection(COL_BOOK) col_doc = get_collection(COL_DOC) docs = list(col_doc.find({'book_id': book_id})) total = len(docs) success = sum(1 for d in docs if d.get('status') in ('html_ready', 'json_ready')) failed = sum(1 for d in docs if d.get('status') == 'failed') new_status = ('pending' if total == 0 else 'done' if failed == 0 else 'failed' if success == 0 else 'partial') book = col_book.find_one({'_id': book_id}) if not book: return upd = { 'total_count': total, 'success_count': success, 'failed_count': failed, 'updated_at': _now(), } if book.get('status') != 'processing': upd['status'] = new_status col_book.update_one({'_id': book_id}, {'$set': upd}) def soft_delete_book(book_id: int) -> bool: col = get_collection(COL_BOOK) r = col.update_one({'_id': book_id, 'is_deleted': 0}, {'$set': {'is_deleted': 1, 'updated_at': _now()}}) return r.matched_count > 0