# 文本编辑器技术架构文档 ## 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](./text-editor-feature-design.md#3-阶段-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 ```python 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](./text-editor-feature-design.md#4-阶段-1word-模板--工作流文档编辑) ### 3.1 样式管理核心逻辑 #### 上传 Word 文档提取样式时 ```python 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 方式注入到新文档的 `` 节点,`DocxRenderer` 渲染各 Markdown 节点时通过 `w:styleId` 直接引用注入后的样式,无需手动设颜色/字号。 #### 上传带占位符模板时 ```python # 解析模板占位符列表 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(模板填充)时 ```python # 将文档内容按标题名称匹配占位符并替换 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 数据库新增 ```sql -- 样式文件缓存表(从上传的 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` ```python 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}` ```python @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 适配两个平台的不同通信方式: ```typescript interface EditorComponentProps { documentId: string; token?: string; // oil-agent 免登录场景传入 platform: 'mod-chat' | 'oil-agent'; apiBaseUrl: string; onSave?: (content: string) => void; onClose?: () => void; } ``` **WS Client 内部实现**: ```typescript 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 配置 ```typescript // vite.config.ts(编辑器打包为远程模块,供两个平台加载) federation({ name: 'editorModule', filename: 'remoteEntry.js', exposes: { './DocumentEditor': './src/components/editor/DocumentEditor', }, shared: ['react', 'react-dom'], }) ``` ### 4.7 Token Service ```python 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 版本管理数据表 ```sql 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 前端团队