Files
book-mcp/docs/book-mcp-design.md
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

374 lines
15 KiB
Markdown
Raw 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.

# 教辅处理 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 确认待定项后开干。