后端 (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→入库→上传→校验)
138 lines
4.9 KiB
Python
138 lines
4.9 KiB
Python
"""教辅 (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 |