后端 (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→入库→上传→校验)
374 lines
15 KiB
Markdown
374 lines
15 KiB
Markdown
# 教辅处理 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` 生成算法**(保证全局唯一 + 路径稳定):
|
||
|
||
```python
|
||
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 个工具就够用了**:
|
||
|
||
1. `create_book` — 必做
|
||
2. `add_document` — 必做
|
||
3. `mark_html_ready` — 必做
|
||
4. `mark_json_ready` — 必做
|
||
5. `mark_failed` — 必做
|
||
6. `update_book_status` — 必做
|
||
7. `get_book` — 必做
|
||
8. `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` 库加表)<br>B. 起新库 `book_mcp`<br>C. 用 SQLite(开发期够用)|
|
||
| **MCP 名字** | `book-mcp` / `exam-book-mcp` / `teach-assist-mcp` |
|
||
| **桶** | 复用 `scan` 还是新开 `book` 桶? |
|
||
| **写入控制** | 要不要签名 URL(防外链)?还是公开? |
|
||
| **MCP 端口** | 8080 / 8100 / 复用 8089 |
|
||
|
||
---
|
||
|
||
## 附录 A: 数据库 schema 草稿
|
||
|
||
```sql
|
||
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 确认待定项后开干。
|