Files
book-mcp/db/models.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

226 lines
8.2 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.

"""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,
}