Files
book-mcp/services/book_service.py
Quentin 9b0910ccb1 Initial commit: book-mcp 教辅处理 MCP server + Arco Vue 管理端
后端 (book_mcp_server.py, 335 行, 15 个 MCP 工具):
- create_book / add_document / mark_html_ready / mark_json_ready / mark_failed
- update_book_status / soft_delete_book / retry_failed
- get_book / list_books / get_document / list_documents / get_document_url
- upload_to_rustfs / health
- 走 JSON-RPC + stateless_http + json_response(前端可直接调)
- launchd 守护 / 6 秒拉起(macOS)
- systemd 守护脚本(Linux/codelab)

存储:
- MongoDB (mongo:8.0 容器) - book/book_document/document_file 三表
- RustFS 对象存储(fs.code-lab.cn/scan/)- book/<id>/docs/<doc_id>/{docx,html,json}
- 幂等设计: zip_sha256 唯一 + (book_id+original_path) 唯一

前端 (Vue 3 + TS + Arco Design):
- 列表页 /book/list - 搜索 + 状态过滤 + 进度
- 详情页 /book/:id - 文档折叠 + 文件下载
- MCP 客户端 src/api/mcp.ts (JSON-RPC over fetch)

部署:
- deploy/install.sh (codelab 一键装 backend + systemd)
- deploy/install-frontend.sh (vite dev / 生产构建)
- docs/deploy.md (运维手册)

测试:
- tests/test_basic.py (sanity 7 项)
- tests/test_e2e.py (完整 e2e: docx→HTML→入库→上传→校验)
2026-10-11 18:03:57 +08:00

138 lines
4.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""教辅 (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