后端 (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→入库→上传→校验)
15 KiB
15 KiB
教辅处理 MCP 服务 — 方案设计
作者: 助手(Quentin 的 AI 助手) 日期: 2026-10-11 状态: 待评审
1. 业务目标
Agent 上传一个教辅 zip → MCP 自动解压 + 批量转 HTML + 上传对象存储 + 入库 → 后续页面可查、可看 HTML。
核心工作流
┌─ Agent 端 ───────────────────────────────────────────┐
│ │
│ 1) Agent: upload_zip(local_zip) → rustfs 返回 key │
│ 2) Agent: mcp.create_book(name, zip_path=key) │
│ ↓ │
│ 返回 {book_id, status="pending"} │
│ │
│ 3) Agent: 本地解压 zip │
│ 4) Agent: 循环每 docx │
│ ├─ mcp.add_document(book_id, filename, path) │
│ │ ↓ 返回 doc_id │
│ ├─ wordparse.docx_to_html(docx) → 本地 html │
│ ├─ mcp.upload_to_rustfs(html, book/doc_id/html) │
│ ├─ Agent 本地解析 html → structure.json │
│ ├─ mcp.upload_to_rustfs(json, book/doc_id/json) │
│ ├─ mcp.mark_html_ready(doc_id, html_url, stats) │
│ └─ mcp.mark_json_ready(doc_id, json_url, ver) │
│ │
│ 5) Agent: mcp.update_book_status(book_id, "done") │
│ │
└─ 后续页面 ───────────────────────────────────────────┘
│ - HTTP API 查 book / doc / file(复用 mcp 的查询类) │
│ - 列表页:分页查 book + 每本完成进度条 │
│ - 详情页:doc 列表 + 跳 html / json 在线预览 │
└──────────────────────────────────────────────────────┘
2. 数据模型
2.1 ER 关系
┌────────────┐ 1 N ┌────────────────┐ 1 N ┌──────────────┐
│ book │─────────│ book_document │─────────│ document_file│
│ 教辅 │ │ 文档 │ │ 文件(多形态) │
└────────────┘ └────────────────┘ └──────────────┘
2.2 实体字段
book 教辅(zip / 套书)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | 自增 |
name |
VARCHAR(200) | 教辅名("2027版.曲一线3•2物理.广西版.一轮复习-突破训练") |
subject |
VARCHAR(50) | 学科(物理/数学/语文/...) |
grade |
VARCHAR(50) | 年级(高三/高一/...) |
version |
VARCHAR(50) | 版本(人教版/广西版/2027版/...) |
type |
VARCHAR(50) | 类型(突破训练/同步训练/真题/...) |
zip_path |
VARCHAR(500) | 原始 zip 在对象存储的路径(如 book/2027-物理-突破训练.zip) |
zip_sha256 |
VARCHAR(64) UNIQUE | 原始 zip 哈希(幂等去重) |
zip_size |
BIGINT | 原始 zip 字节数 |
status |
ENUM | pending / processing / done / failed / partial |
total_count |
INT | zip 内 docx 总数 |
success_count |
INT | 成功转 HTML 数 |
failed_count |
INT | 失败数 |
error_msg |
TEXT | 失败原因 |
created_at |
DATETIME | 创建时间 |
updated_at |
DATETIME | 处理完成时间 |
created_by |
VARCHAR(100) | 哪个 Agent / 哪个用户传 |
is_deleted |
TINYINT | 软删标记(0/1) |
唯一索引: uk_name_version (name, version, is_deleted) 防同名教辅重复创建
book_document 文档(zip 里的每个 docx 一条)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | 自增 |
book_id |
BIGINT FK→book.id | 属于哪本教辅 |
doc_id |
VARCHAR(64) UNIQUE | 本服务的核心 ID(算法见下) |
original_filename |
VARCHAR(200) | 原始文件名 |
original_path |
VARCHAR(500) | zip 内的相对路径 |
title |
VARCHAR(200) | 解析出的标题(如能从 docx 提取) |
doc_type |
ENUM | 课时作业 / 周末提升 / 阶段质量评估 / 综合评估 / unknown |
order_index |
INT | 在教辅中的顺序(按文件名排序) |
status |
ENUM | pending / html_ready / json_ready / failed |
error_msg |
TEXT | 失败原因 |
created_at |
DATETIME | |
updated_at |
DATETIME |
doc_id 生成算法(保证全局唯一 + 路径稳定):
doc_id = sha1(f"{book_id}|{original_path}").hexdigest()[:16]
# 例: 5b3a2f1e9c0d4b7a
- 同本书同路径永远同一 doc_id
- 重复传同一 zip 不会重复入库(幂等)
- 不带敏感信息(文件名脱敏)
索引: uk_doc_id, idx_book_id (book_id, order_index)
document_file 文件(HTML / JSON / 原 docx 多产物)
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT PK | 自增 |
doc_id |
VARCHAR(64) FK→book_document.doc_id | 关联到文档 |
file_type |
ENUM | docx / html / json / preview_png / error_log |
object_key |
VARCHAR(500) | 对象存储 key |
object_bucket |
VARCHAR(50) | 桶名 |
size |
BIGINT | 字节数 |
checksum |
VARCHAR(64) | sha256 |
url |
VARCHAR(500) | 公开 URL(如果是公开桶) |
meta |
JSON | 附加信息:公式数、图片数、字符数等 |
created_at |
DATETIME |
索引: uk_doc_type (doc_id, file_type), idx_bucket_key (object_bucket, object_key)
一个 doc 对应 3-5 条 file 记录: 原 docx + html + json + (可选) 错误日志
3. 对象存储路径结构
<bucket>/
└── book/
└── <book_id_or_slug>/ # book-{id}-{slug}
└── docs/
└── <doc_id>/ # sha1 短 hash
├── source.docx # 原 docx(备份用)
├── output.html # 转的 HTML(含 MathJax)
├── structure.json # Agent 后续结构化输出
├── preview.png # 可选:首页截图
└── error.log # 仅失败时有
优点:
- 同 doc_id 所有产物都在一个目录
- 删教辅 → 删整个
<book_id>/前缀 - 列表时一次 list
<book_id>/docs/拿到全部
4. MCP 工具集
4.1 写入类
| # | 工具 | 签名 | 返回 |
|---|---|---|---|
| 1 | create_book |
(name, zip_path, subject?, grade?, version?, type?, created_by?) |
{book_id, status, total_count} |
| 2 | add_document |
(book_id, original_filename, original_path, zip_size?) |
{doc_id, order_index, status} |
| 3 | mark_html_ready |
(doc_id, html_url, html_size, stats: {formulas, images, chars}) |
{ok, file_id} |
| 4 | mark_json_ready |
(doc_id, json_url, json_size, schema_version) |
{ok, file_id} |
| 5 | mark_failed |
(doc_id, error_msg, stage: "html" | "json" | "upload") |
{ok, status} |
| 6 | update_book_status |
(book_id, status, error_msg?) |
{ok} |
| 15 | upload_to_rustfs |
(local_path, key, bucket?) |
{url, key, size, checksum} |
4.2 查询类
| # | 工具 | 签名 | 返回 |
|---|---|---|---|
| 7 | get_book |
(book_id) |
{book + documents[] + files[]} |
| 8 | list_books |
(status?, page?, page_size?, keyword?) |
{books[], total, page, page_size} |
| 9 | get_document |
(doc_id) |
{document + files[]} |
| 10 | list_documents |
(book_id, status?, page?, page_size?) |
{documents[], total} |
| 11 | get_document_files |
(doc_id) |
{files[]} |
| 12 | get_document_url |
(doc_id, file_type: "html"|"json"|"docx") |
{url, expires_in} |
4.3 辅助类
| # | 工具 | 签名 | 返回 |
|---|---|---|---|
| 13 | delete_book |
(book_id, hard?: bool) |
{ok, deleted_files} |
| 14 | retry_failed |
(book_id) |
{retried_count, ok, failed_count} |
4.4 工具数量
- v1 最小可用: 8 个(
create_book/add_document/mark_html_ready/mark_json_ready/mark_failed/update_book_status/get_book/list_books) - v2 完整: 加
get_document/list_documents/get_document_files/get_document_url - v3 兜底: 加
delete_book/retry_failed/upload_to_rustfs
5. 技术栈选型
| 层 | 选型 | 理由 |
|---|---|---|
| MCP 框架 | fastmcp (Python 3.13) |
跟 wordparse_server.py 一致,<10 行起 server |
| HTTP 框架 | 不另起 — fastmcp 自动暴露 HTTP | 省一个服务 |
| 数据库 | MySQL / PolarDB | 跟 jby 同栈;SQLAlchemy 2.0 + alembic |
| 对象存储 | RustFS fs.code-lab.cn(你已有) |
同 wordparse |
| Word→HTML | 复用 wordparse_server.py 的 docx_to_html |
不重写,自动清洗已集成 |
| HTML→JSON | Agent 调 LLM 自己做 | 复杂结构化逻辑,放业务侧 |
| 配置 | YAML | 跟 wordparse 一致 |
6. 项目目录
book-mcp/
├── config.yaml # db / rustfs / 默认桶
├── book_mcp_server.py # MCP 入口(fastmcp)
├── db/
│ ├── models.py # SQLAlchemy ORM
│ ├── migrations/ # alembic
│ └── schema.sql # 一份纯 SQL(给 DBA / 手动建表用)
├── services/
│ ├── book_service.py # book CRUD
│ ├── document_service.py # doc CRUD
│ ├── file_service.py # file CRUD
│ └── rustfs_service.py # 上传 / 删
├── tests/
└── README.md
7. 关键设计决策
| 问题 | 决策 | 理由 |
|---|---|---|
用 book_id 还是 name 查重? |
name + version 唯一索引 | 同名教辅重复传不创建新 book |
| 同一 zip 多次上传? | 幂等(按 zip sha256 去重 book) | 防呆 |
| docx 处理失败怎么办? | book.status = "partial",不阻塞整本 |
容错,UI 标黄提醒 |
| html 公开还是私有? | 公开(读教辅是低频操作) | 简化 URL 生成 |
| json 谁生成? | Agent 调 LLM | 强业务相关,不放 MCP |
| MCP 一次返回多少文档? | list_documents 分页 50/页 |
防大书 OOM |
| 数据库有没有? | 独立新库 book_mcp |
避免污染 jby schema |
| 删除能级? | 软删(is_deleted 字段) |
防误删 |
| 后续页面怎么查? | 直接调 MCP 的查询类工具(fastmcp 自动暴露 HTTP) | 不写新 API |
8. v1 范围(最小可用)
做这 8 个工具就够用了:
create_book— 必做add_document— 必做mark_html_ready— 必做mark_json_ready— 必做mark_failed— 必做update_book_status— 必做get_book— 必做list_books— 必做
v2 再加: get_document / list_documents / get_document_files / get_document_url / delete_book / retry_failed
v1 不做:
- 鉴权(内网/白名单)
- 全文搜索(用对象存储 + ES 后续加)
- 文件版本管理(覆盖式)
- 异步任务队列(同步够用,每本 ~3-5 分钟)
9. 风险 & 备选
| 风险 | 应对 |
|---|---|
| 上传 1000+ docx 的大教辅会卡 Agent | add_document 一次传 1 个,避免事务过大;Agent 用并发池 |
| docx 转 HTML 全失败(脏文件) | 用之前加的 _clean_docx_xml 自动清洗 |
| 对象存储 URL 变(key 改了) | 数据库记 object_key,URL 重新生成 |
| MCP server 自己挂了 | launchd 保活 + 自动重启(参考 paop gateway) |
10. 待确认事项
| 待定 | 选项 |
|---|---|
| 数据库 | A. 复用 jby PolarDB(questions-bank 库加表)B. 起新库 book_mcpC. 用 SQLite(开发期够用) |
| MCP 名字 | book-mcp / exam-book-mcp / teach-assist-mcp |
| 桶 | 复用 scan 还是新开 book 桶? |
| 写入控制 | 要不要签名 URL(防外链)?还是公开? |
| MCP 端口 | 8080 / 8100 / 复用 8089 |
附录 A: 数据库 schema 草稿
CREATE TABLE book (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
subject VARCHAR(50),
grade VARCHAR(50),
version VARCHAR(50),
type VARCHAR(50),
zip_path VARCHAR(500) NOT NULL,
zip_sha256 VARCHAR(64) NOT NULL,
zip_size BIGINT NOT NULL DEFAULT 0,
status ENUM('pending','processing','done','failed','partial') NOT NULL DEFAULT 'pending',
total_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
failed_count INT NOT NULL DEFAULT 0,
error_msg TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by VARCHAR(100),
is_deleted TINYINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_zip_sha (zip_sha256),
UNIQUE KEY uk_name_version (name, version, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE book_document (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
book_id BIGINT NOT NULL,
doc_id VARCHAR(64) NOT NULL,
original_filename VARCHAR(200) NOT NULL,
original_path VARCHAR(500) NOT NULL,
title VARCHAR(200),
doc_type ENUM('课时作业','周末提升','阶段质量评估','综合评估','unknown') NOT NULL DEFAULT 'unknown',
order_index INT NOT NULL DEFAULT 0,
status ENUM('pending','html_ready','json_ready','failed') NOT NULL DEFAULT 'pending',
error_msg TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_doc_id (doc_id),
INDEX idx_book_order (book_id, order_index),
CONSTRAINT fk_book_doc FOREIGN KEY (book_id) REFERENCES book(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE document_file (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
doc_id VARCHAR(64) NOT NULL,
file_type ENUM('docx','html','json','preview_png','error_log') NOT NULL,
object_key VARCHAR(500) NOT NULL,
object_bucket VARCHAR(50) NOT NULL,
size BIGINT NOT NULL DEFAULT 0,
checksum VARCHAR(64),
url VARCHAR(500),
meta JSON,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_doc_type (doc_id, file_type),
INDEX idx_bucket_key (object_bucket, object_key),
CONSTRAINT fk_file_doc FOREIGN KEY (doc_id) REFERENCES book_document(doc_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
附录 B: 状态机
book.status 状态机:
pending → processing → done (全部成功)
└──→ partial (有失败)
└──→ failed (完全失败,异常中断)
book_document.status 状态机:
pending → html_ready → json_ready (正常完成)
└──→ failed (任何一步失败,可重试)
END — 等 Quentin 确认待定项后开干。