# 教辅处理 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. 对象存储路径结构 ``` / └── book/ └── / # book-{id}-{slug} └── docs/ └── / # sha1 短 hash ├── source.docx # 原 docx(备份用) ├── output.html # 转的 HTML(含 MathJax) ├── structure.json # Agent 后续结构化输出 ├── preview.png # 可选:首页截图 └── error.log # 仅失败时有 ``` **优点**: - 同 doc_id 所有产物都在一个目录 - 删教辅 → 删整个 `/` 前缀 - 列表时一次 list `/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` 库加表)
B. 起新库 `book_mcp`
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 确认待定项后开干。