text-editor-architecture.md 20 KB

文本编辑器技术架构文档

1. 整体架构演进

系统按阶段渐进引入组件,每个阶段只加当前必要的依赖。


2. 阶段 0:基础链路(当前)

目标:打通 mod-chat 中"AI 生成文档 → 编辑器打开 → 编辑 → 保存回 Chat"闭环。

┌──────────────────────────────────────┐
│           mod-chat(前端)             │
│  Chat UI ──► DocumentEditor Modal     │
│               (Slate.js)            │
└───────────────────┬──────────────────┘
                    │ REST API
                    ▼
┌──────────────────────────────────────┐
│        FastAPI 后端(单服务)           │
│   /api/v1/documents      (CRUD)     │
│   /api/v1/export/doc     (导出)     │
│   /api/v1/export/records (下载记录) │
│   /api/v1/admin/storage  (存储监控) │
└───────────┬──────────────────┬───────┘
            │ SQLAlchemy       │ 本地文件系统
            ▼                  ▼
       PostgreSQL         ./tmp/{user_id}/
  (documents 表          {YYYY-MM-DD}/
   export_records 表)    *.doc 文件

依赖清单:FastAPI、SQLAlchemy、PostgreSQL、python-docx、mistune
无需:Redis、S3、Token 服务、Webhook、API Gateway

前端组件结构、状态管理设计见 功能设计文档 - 阶段 0

2.1 导出与存储监控

导出流程由 export_service.py 承担核心逻辑:接收 Markdown 内容 → 加载默认样式文件(./tmp/default.json)→ 通过 DocxRenderer(基于 mistune AST + python-docx)渲染为 .doc 字节流 → 按 ./tmp/{user_id}/{YYYY-MM-DD}/ 分区写入磁盘 → 写入 export_records 表并返回永久下载链接。

storage_monitor.py 在应用启动时注册后台定时任务(每 30 分钟),并在每次导出后触发实时检查:tmp/ 总占用超磁盘 50% 时向所有有文件用户发警告并记录管理员日志;单用户超均分额度时在导出响应的 warning 字段附带提示。

下载记录的增删查由 export_record_service.py 管理,删除时同步硬删除磁盘文件。

2.2 后端 Document Service

class DocumentService:
    def __init__(self, db: AsyncSession):
        self.db = db

    async def create_document(self, data: DocumentCreate, user_id: str | None = None) -> Document:
        doc = Document(
            title=data.title,
            content=data.content,   # 直接存 text,上限 200KB
            format=data.format,
            session_id=data.session_id,
            created_by=user_id,
            source='chat',
        )
        self.db.add(doc)
        await self.db.commit()
        await self.db.refresh(doc)
        return doc

    async def update_document(self, document_id: str, data: DocumentUpdate) -> Document:
        doc = await self.get_document(document_id)
        if not doc:
            raise ValueError("Document not found")
        if data.content and len(data.content) > 200_000:
            raise ValueError("Content exceeds 200KB limit")
        if data.title is not None:
            doc.title = data.title
        if data.content is not None:
            doc.content = data.content
        doc.updated_at = datetime.utcnow()
        await self.db.commit()
        return doc

3. 阶段 1:Word 模板 + 工作流文档编辑

目标:支持用户上传 Word 文档提取全量样式,导出 .doc 时可选择自定义样式;支持上传带占位符的 Word 模板,工作流生成文档后下载保持模板格式。

用户上传 Word 文档(样式提取)/ 带占位符模板(模板管理)
    │
    ▼
┌─────────────────────────────────────────┐
│              FastAPI 后端                │
│  ├── /api/v1/styles      (样式管理)    │
│  ├── /api/v1/templates   (模板管理)    │
│  ├── /api/v1/documents   (CRUD)        │
│  ├── /api/v1/export/doc  (样式导出)    │
│  └── /api/v1/export/docx (模板填充导出)│
└────────────────┬────────────────────────┘
       ┌─────────┴──────────┐
       ▼                    ▼
  PostgreSQL             文件存储
(文档 + 样式/模板元数据) (样式 JSON + 模板 .docx)
               │
         python-docx
    (样式 XML 提取 + 注入 / 模板内容合并)

新增依赖python-docx(已在阶段 0 引入)、docxtpl(可选,模板填充)

前端编辑器应用模板样式的实现见 功能设计文档 - 阶段 1

3.1 样式管理核心逻辑

上传 Word 文档提取样式时

from docx import Document as DocxDocument
from lxml import etree

def element_to_dict(elem) -> dict:
    """递归将 lxml Element 转为嵌套字典,保留所有属性、子元素和命名空间。"""
    d = {"@tag": elem.tag, "@attrib": dict(elem.attrib)}
    if elem.text:
        d["#text"] = elem.text
    children = {}
    for child in elem:
        child_dict = element_to_dict(child)
        tag = child.tag
        if tag in children:
            existing = children[tag]
            if not isinstance(existing, list):
                children[tag] = [existing]
            children[tag].append(child_dict)
        else:
            children[tag] = child_dict
    if children:
        d["@children"] = children
    return d

def extract_styles(docx_path: str) -> dict:
    """提取文档中所有样式的完整 XML 定义,保留字体摘要和段落格式摘要。"""
    doc = DocxDocument(docx_path)
    styles = []
    for style in doc.styles:
        styles.append({
            "name": style.name,
            "style_id": style.style_id,
            "type": str(style.type),
            "builtin": style.builtin,
            "hidden": style.hidden,
            "quick_style": style.quick_style,
            # ... font_summary、paragraph_format_summary 同理
            "full_xml_definition": element_to_dict(style.element),
        })
    return {"source_file": docx_path, "total_styles": len(styles), "styles": styles}

导出时,inject_styles_from_json() 将 JSON 中所有样式的 full_xml_definition 以 upsert 方式注入到新文档的 <w:styles> 节点,DocxRenderer 渲染各 Markdown 节点时通过 w:styleId 直接引用注入后的样式,无需手动设颜色/字号。

上传带占位符模板时

# 解析模板占位符列表
import re
from docx import Document as DocxDocument

def parse_template_placeholders(docx_path: str) -> list[str]:
    doc = DocxDocument(docx_path)
    placeholders = []
    pattern = re.compile(r'\{\{(.+?)\}\}')
    for para in doc.paragraphs:
        matches = pattern.findall(para.text)
        placeholders.extend(matches)
    return list(dict.fromkeys(placeholders))  # 去重保序

导出 DOCX(模板填充)时

# 将文档内容按标题名称匹配占位符并替换
from docxtpl import DocxTemplate

def export_with_template(template_path: str, content_blocks: dict) -> bytes:
    tpl = DocxTemplate(template_path)
    context = {name: content for name, content in content_blocks.items()}
    tpl.render(context)
    buffer = io.BytesIO()
    tpl.save(buffer)
    return buffer.getvalue()

3.2 数据库新增

-- 样式文件缓存表(从上传的 Word 文档提取)
CREATE TABLE style_files (
    id          VARCHAR PRIMARY KEY,
    name        VARCHAR NOT NULL,         -- 样式文件名(源文档文件名去扩展名)
    source_file VARCHAR NOT NULL,         -- 原始上传文件名(含扩展名)
    file_path   VARCHAR NOT NULL,         -- 样式 JSON 文件存储路径
    summary     JSONB,                    -- 摘要(默认字体、标题字体、样式总数等)
    is_default  BOOLEAN DEFAULT FALSE,
    created_at  TIMESTAMP
);

-- 模板表(带占位符的 Word 文档)
CREATE TABLE document_templates (
    id           VARCHAR PRIMARY KEY,
    name         VARCHAR NOT NULL,
    file_key     VARCHAR NOT NULL,        -- 模板文件存储路径
    placeholders JSONB,                   -- 占位符名称列表
    has_header   BOOLEAN DEFAULT FALSE,
    has_footer   BOOLEAN DEFAULT FALSE,
    created_at   TIMESTAMP
);

-- documents 表新增字段
ALTER TABLE documents ADD COLUMN template_id VARCHAR REFERENCES document_templates(id);
ALTER TABLE documents ADD COLUMN source VARCHAR DEFAULT 'chat';

4. 阶段 2:oil-agent 跨平台集成

目标:将编辑器封装为独立的 Editor Component + WS Client,Backend 居中,mod-chat 通过 REST/SSE 与 Backend 通信,oil-agent 通过 REST/WebSocket 与 Backend 通信。

4.1 整体架构

┌─────────────────┐          ┌──────────────┐          ┌─────────────┐
│    mod-chat     │          │   Backend    │          │  oil-agent  │
│                 │          │   (Python)   │          │             │
│ ┌─────────────┐ │  REST/   │ ┌──────────┐ │  REST/   │ ┌─────────┐ │
│ │   Editor    │ |◄────────►│ │ Document │ |◄────────►│ │  Editor │ │
│ │  Component  │ │   SSE    │ │ Service  │ │WebSocket │ │Component│ │
│ │  + WS Cli   │ │          │ └──────────┘ │          │ └─────────┘ │
│ └─────────────┘ │          │              │          │             │
└─────────────────┘          └──────┬───────┘          └─────────────┘
                                    │
                       ┌────────────┴────────────┐
                       ▼                         ▼
                  PostgreSQL                   Redis
               (文档 + 模板)              (Token 会话)

4.2 通信链路说明

链路 1:mod-chat ↔ Backend(REST/SSE)

mod-chat 是 Axonix 自有平台,Editor Component 内置 WS Client,对外同时支持 SSE 监听(接收后端推送)和 REST 请求(主动发送操作):

  • mod-chat Editor Component 通过 REST 发起请求(创建文档、保存内容等)
  • WS Client 建立 SSE 连接,后端通过 SSE 主动推送事件给 mod-chat(如文档被 oil-agent 端修改后通知更新)
后端检测到文档内容变更
    ↓
SSE 推送 document.updated 事件到 mod-chat
    ↓
mod-chat Editor Component 收到事件,自动拉取最新内容

SSE 连接地址:GET /api/v1/documents/{documentId}/events

from sse_starlette.sse import EventSourceResponse

@router.get("/documents/{document_id}/events")
async def document_events(document_id: str):
    async def event_generator():
        while True:
            event = await get_document_event(document_id)
            if event:
                yield {"event": event.type, "data": event.json()}
            await asyncio.sleep(1)
    return EventSourceResponse(event_generator())

链路 2:oil-agent ↔ Backend(REST/WebSocket)

oil-agent 是外部平台,SSE 可能受限于其网络环境或代理配置不被支持,改用兼容性更强的 WebSocket:

  • oil-agent Editor Component 通过 REST 发起普通请求(Token 校验、文档读取等)
  • 通过 WebSocket 维持与后端的双向长连接,实时同步编辑内容变更
oil-agent Editor Component 建立 WebSocket 连接(携带 Token)
    ↓
双向通信:编辑器推送内容变更 / 后端推送文档更新
    ↓
编辑器关闭时断开连接,后端触发 session.closed → SSE 通知 mod-chat

WebSocket 地址:ws://api.axonix.com/ws/edit-sessions/{sessionId}?token={token}

@router.websocket("/ws/edit-sessions/{session_id}")
async def websocket_edit_session(websocket: WebSocket, session_id: str, token: str):
    payload = await token_service.verify_token(token)
    if not payload:
        await websocket.close(code=4001)
        return
    await websocket.accept()
    try:
        while True:
            data = await websocket.receive_json()
            await handle_edit_message(session_id, data)
            # 广播变更到 SSE,通知 mod-chat 端同步
            await broadcast_to_sse(payload["document_id"], data)
    except WebSocketDisconnect:
        await close_session(session_id)

4.3 通信方式对比

链路 方式 原因
mod-chat ↔ Backend REST + SSE 自有平台,WS Client 通过 SSE 接收后端推送;REST 发送操作请求
oil-agent ↔ Backend REST + WebSocket 外部平台,SSE 可能不被支持;WebSocket 双向通信兼容性强

4.4 数据存储职责

存储 内容 备注
PostgreSQL 文档内容、文档元数据、模板元数据 阶段 1 新增模板表
Redis Token 会话(edit_token、edit_session) 阶段 2 引入,短效 KV,不落盘

4.5 Editor Component 设计

Editor Component 通过 Props 适配两个平台的不同通信方式:

interface EditorComponentProps {
  documentId: string;
  token?: string;                        // oil-agent 免登录场景传入
  platform: 'mod-chat' | 'oil-agent';
  apiBaseUrl: string;
  onSave?: (content: string) => void;
  onClose?: () => void;
}

WS Client 内部实现

class EditorTransport {
  private sse: EventSource | null = null;
  private ws: WebSocket | null = null;

  connect(platform: 'mod-chat' | 'oil-agent', url: string) {
    if (platform === 'mod-chat') {
      // mod-chat:SSE 监听后端推送,REST 发送请求
      this.sse = new EventSource(url);
      this.sse.onmessage = (e) => this.handleEvent(JSON.parse(e.data));
    } else {
      // oil-agent:WebSocket 双向通信
      this.ws = new WebSocket(url);
      this.ws.onmessage = (e) => this.handleEvent(JSON.parse(e.data));
    }
  }

  send(data: object) {
    if (this.ws) {
      // oil-agent:WebSocket 直接发
      this.ws.send(JSON.stringify(data));
    }
    // mod-chat:发送走 REST API,不通过 SSE
  }

  disconnect() {
    this.sse?.close();
    this.ws?.close();
  }
}

4.6 Module Federation 配置

// vite.config.ts(编辑器打包为远程模块,供两个平台加载)
federation({
  name: 'editorModule',
  filename: 'remoteEntry.js',
  exposes: {
    './DocumentEditor': './src/components/editor/DocumentEditor',
  },
  shared: ['react', 'react-dom'],
})

4.7 Token Service

class TokenService:
    async def generate_edit_token(
        self,
        document_id: str,
        platform: str,
        expires_in: int = 3600,
    ) -> tuple[str, str]:
        session_id = f"edit_session_{secrets.token_urlsafe(16)}"
        payload = {
            "session_id": session_id,
            "document_id": document_id,
            "platform": platform,
            "exp": datetime.utcnow() + timedelta(seconds=expires_in),
        }
        token = jwt.encode(payload, self.secret_key, algorithm="HS256")
        await redis.setex(f"edit_token:{session_id}", expires_in, token)
        return session_id, token

    async def verify_token(self, token: str) -> dict | None:
        try:
            payload = jwt.decode(token, self.secret_key, algorithms=["HS256"])
            stored = await redis.get(f"edit_token:{payload['session_id']}")
            return payload if stored else None
        except jwt.InvalidTokenError:
            return None

新增依赖:Redis、PyJWT、sse-starlette(SSE 端点)、Module Federation(前端构建)


5. 阶段 3:编辑器增强 + 协同编辑

目标:完善编辑体验,支持版本管理,按需启用多人协同。

新增依赖:Yjs、y-websocket、S3/MinIO(大文档迁移)、API Gateway

5.1 协同编辑架构(Yjs)

┌───────────────────────┐     ┌───────────────────────┐
│  Client A             │     │  Client B             │
│  Slate.js + y-slate   │     │  Slate.js + y-slate   │
│  Yjs Doc(本地副本)   │     │  Yjs Doc(本地副本)   │
└──────────┬────────────┘     └────────────┬──────────┘
           │  CRDT 增量操作                  │  CRDT 增量操作
           └─────────────┬──────────────────┘
                         │ WebSocket
                         ▼
              ┌──────────────────────┐
              │   y-websocket Server │
              │   合并 CRDT 操作      │
              │   广播给所有客户端     │
              └──────────┬───────────┘
                         │
                    PostgreSQL
                (持久化版本快照)

CRDT 工作原理:每个客户端持有文档的本地副本,编辑操作转为 CRDT 增量(不是全量内容),服务端合并所有客户端的增量后广播,冲突由算法自动解决,无需人工干预。

5.2 大文档迁移到 S3

阶段 3 引入 S3/MinIO 后,超过 200KB 的文档内容从 PostgreSQL text 字段迁移到对象存储:

documents 表
  content      → NULL(大文档不再存 DB)
  content_key  → "documents/doc-abc123.md"  (S3 对象路径)

读取时按 content_key 从 S3 拉取,写入时上传到 S3 再更新 content_key

5.3 版本管理数据表

CREATE TABLE document_versions (
    id          VARCHAR PRIMARY KEY,
    document_id VARCHAR NOT NULL,
    content     TEXT NOT NULL,
    created_by  VARCHAR,
    summary     VARCHAR,
    created_at  TIMESTAMP
);

6. 技术栈汇总

组件 技术 引入阶段
前端框架 React 18 + TypeScript + Vite 阶段 0
UI 组件库 Ant Design 阶段 0
编辑器核心 Slate.js 阶段 0
状态管理 Zustand 阶段 0
后端框架 FastAPI(Python 3.10+) 阶段 0
ORM SQLAlchemy 2.0 阶段 0
数据库 PostgreSQL 阶段 0
DOCX 处理 python-docx + mistune(阶段 0 引入);docxtpl(阶段 1 模板填充,可选) 阶段 0/1
Token 会话 Redis + PyJWT 阶段 2
微前端打包 Module Federation(Vite) 阶段 2
实时协同 Yjs + y-websocket 阶段 3
大文档存储 S3 / MinIO 阶段 3
网关 API Gateway 阶段 3

7. 安全设计

阶段 0/1

  • 编辑器 API 复用 mod-chat 现有认证,无额外层

阶段 2 引入

  • JWT + Redis 双重校验,Token 与文档 ID 绑定
  • Token 默认 1 小时,最长 24 小时,关闭会话立即撤销

通用

  • 用户输入 HTML 经 DOMPurify 过滤后存储
  • 文件上传限制类型(.docx)和大小(≤ 20MB)
  • API 限流防滥用

8. 性能基准目标

指标 目标值
编辑器首次加载 < 2s
文档保存响应 < 500ms
DOCX 导出生成 < 3s
10 万字文档渲染 流畅(60fps)
并发协同用户 50 人/文档(阶段 3)

文档版本: v5.0 最后更新: 2026-06-18 维护者: Axonix 前端团队