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→入库→上传→校验)
This commit is contained in:
2026-10-11 18:03:45 +08:00
parent f7fb571bce
commit 9b0910ccb1
160 changed files with 23628 additions and 3 deletions

226
db/models.py Normal file
View File

@@ -0,0 +1,226 @@
"""MongoDB 文档模型 — helper class(非 ORM,纯字典封装)
3 个 collection:
Book → book
BookDocument → book_document
DocumentFile → document_file
每个 class 有 to_mongo / from_mongo / to_dict 等方法。
"""
import hashlib
from datetime import datetime, timezone
from typing import Optional
def _now() -> datetime:
return datetime.now(timezone.utc)
# ============================================================
# Book — 教辅
# ============================================================
class Book:
"""教辅文档。_id = book_id(int)"""
COLLECTION = 'book'
def __init__(self, **kwargs):
self.id: Optional[int] = kwargs.get('id')
self.name: str = kwargs['name']
self.subject: Optional[str] = kwargs.get('subject')
self.grade: Optional[str] = kwargs.get('grade')
self.version: Optional[str] = kwargs.get('version')
self.type: Optional[str] = kwargs.get('type')
self.zip_path: str = kwargs['zip_path']
self.zip_sha256: str = kwargs['zip_sha256']
self.zip_size: int = kwargs.get('zip_size', 0)
self.status: str = kwargs.get('status', 'pending')
self.total_count: int = kwargs.get('total_count', 0)
self.success_count: int = kwargs.get('success_count', 0)
self.failed_count: int = kwargs.get('failed_count', 0)
self.error_msg: Optional[str] = kwargs.get('error_msg')
self.created_at: datetime = kwargs.get('created_at', _now())
self.updated_at: datetime = kwargs.get('updated_at', _now())
self.created_by: Optional[str] = kwargs.get('created_by')
self.is_deleted: int = kwargs.get('is_deleted', 0)
def to_mongo(self) -> dict:
d = {
'name': self.name,
'subject': self.subject,
'grade': self.grade,
'version': self.version,
'type': self.type,
'zip_path': self.zip_path,
'zip_sha256': self.zip_sha256,
'zip_size': self.zip_size,
'status': self.status,
'total_count': self.total_count,
'success_count': self.success_count,
'failed_count': self.failed_count,
'error_msg': self.error_msg,
'created_at': self.created_at,
'updated_at': self.updated_at,
'created_by': self.created_by,
'is_deleted': self.is_deleted,
}
if self.id is not None:
d['_id'] = self.id
return d
@classmethod
def from_mongo(cls, doc: dict) -> 'Book':
if not doc:
return None
d = dict(doc)
d['id'] = d.pop('_id', None)
return cls(**d)
def to_dict(self) -> dict:
return {
'id': self.id,
'name': self.name,
'subject': self.subject,
'grade': self.grade,
'version': self.version,
'type': self.type,
'zip_path': self.zip_path,
'zip_sha256': self.zip_sha256,
'zip_size': self.zip_size,
'status': self.status,
'total_count': self.total_count,
'success_count': self.success_count,
'failed_count': self.failed_count,
'error_msg': self.error_msg,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'created_by': self.created_by,
}
# ============================================================
# BookDocument — 教辅文档
# ============================================================
class BookDocument:
"""教辅文档。_id = doc_id(16 字符 sha1 前缀)"""
COLLECTION = 'book_document'
DOC_TYPES = ('课时作业', '周末提升', '阶段质量评估', '综合评估', 'unknown')
STATUSES = ('pending', 'html_ready', 'json_ready', 'failed')
def __init__(self, **kwargs):
self.book_id: int = kwargs['book_id']
self.original_filename: str = kwargs['original_filename']
self.original_path: str = kwargs['original_path']
self.doc_id: str = kwargs.get('doc_id') or self.make_doc_id(
kwargs['book_id'], kwargs['original_path'])
self.title: Optional[str] = kwargs.get('title')
self.doc_type: str = kwargs.get('doc_type', 'unknown')
self.order_index: int = kwargs.get('order_index', 0)
self.status: str = kwargs.get('status', 'pending')
self.error_msg: Optional[str] = kwargs.get('error_msg')
self.created_at: datetime = kwargs.get('created_at', _now())
self.updated_at: datetime = kwargs.get('updated_at', _now())
@staticmethod
def make_doc_id(book_id: int, original_path: str) -> str:
"""稳定 doc_id:sha1(book_id|path).hexdigest()[:16]"""
h = hashlib.sha1(f"{book_id}|{original_path}".encode('utf-8')).hexdigest()
return h[:16]
def to_mongo(self) -> dict:
return {
'_id': self.doc_id,
'book_id': self.book_id,
'original_filename': self.original_filename,
'original_path': self.original_path,
'title': self.title,
'doc_type': self.doc_type,
'order_index': self.order_index,
'status': self.status,
'error_msg': self.error_msg,
'created_at': self.created_at,
'updated_at': self.updated_at,
}
@classmethod
def from_mongo(cls, doc: dict) -> 'BookDocument':
if not doc:
return None
d = dict(doc)
d['doc_id'] = d.pop('_id')
return cls(**d)
def to_dict(self) -> dict:
return {
'book_id': self.book_id,
'doc_id': self.doc_id,
'original_filename': self.original_filename,
'original_path': self.original_path,
'title': self.title,
'doc_type': self.doc_type,
'order_index': self.order_index,
'status': self.status,
'error_msg': self.error_msg,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
# ============================================================
# DocumentFile — 文档文件
# ============================================================
class DocumentFile:
"""文档文件。_id = 自增 int(next_id('file'))"""
COLLECTION = 'document_file'
FILE_TYPES = ('docx', 'html', 'json', 'preview_png', 'error_log')
def __init__(self, **kwargs):
self.id: Optional[int] = kwargs.get('id')
self.doc_id: str = kwargs['doc_id']
self.file_type: str = kwargs['file_type']
self.object_key: str = kwargs['object_key']
self.object_bucket: str = kwargs['object_bucket']
self.size: int = kwargs.get('size', 0)
self.checksum: Optional[str] = kwargs.get('checksum')
self.url: Optional[str] = kwargs.get('url')
self.meta: Optional[dict] = kwargs.get('meta')
self.created_at: datetime = kwargs.get('created_at', _now())
def to_mongo(self) -> dict:
d = {
'doc_id': self.doc_id,
'file_type': self.file_type,
'object_key': self.object_key,
'object_bucket': self.object_bucket,
'size': self.size,
'checksum': self.checksum,
'url': self.url,
'meta': self.meta,
'created_at': self.created_at,
}
if self.id is not None:
d['_id'] = self.id
return d
@classmethod
def from_mongo(cls, doc: dict) -> 'DocumentFile':
if not doc:
return None
d = dict(doc)
d['id'] = d.pop('_id')
return cls(**d)
def to_dict(self) -> dict:
return {
'id': self.id,
'doc_id': self.doc_id,
'file_type': self.file_type,
'object_key': self.object_key,
'object_bucket': self.object_bucket,
'size': self.size,
'checksum': self.checksum,
'url': self.url,
'meta': self.meta,
'created_at': self.created_at.isoformat() if self.created_at else None,
}