系统按阶段渐进引入组件,每个阶段只加当前必要的依赖。
目标:打通 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
导出流程由 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 管理,删除时同步硬删除磁盘文件。
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
目标:支持用户上传 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
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)) # 去重保序
# 将文档内容按标题名称匹配占位符并替换
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()
-- 样式文件缓存表(从上传的 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';
目标:将编辑器封装为独立的 Editor Component + WS Client,Backend 居中,mod-chat 通过 REST/SSE 与 Backend 通信,oil-agent 通过 REST/WebSocket 与 Backend 通信。
┌─────────────────┐ ┌──────────────┐ ┌─────────────┐
│ mod-chat │ │ Backend │ │ oil-agent │
│ │ │ (Python) │ │ │
│ ┌─────────────┐ │ REST/ │ ┌──────────┐ │ REST/ │ ┌─────────┐ │
│ │ Editor │ |◄────────►│ │ Document │ |◄────────►│ │ Editor │ │
│ │ Component │ │ SSE │ │ Service │ │WebSocket │ │Component│ │
│ │ + WS Cli │ │ │ └──────────┘ │ │ └─────────┘ │
│ └─────────────┘ │ │ │ │ │
└─────────────────┘ └──────┬───────┘ └─────────────┘
│
┌────────────┴────────────┐
▼ ▼
PostgreSQL Redis
(文档 + 模板) (Token 会话)
链路 1:mod-chat ↔ Backend(REST/SSE)
mod-chat 是 Axonix 自有平台,Editor Component 内置 WS Client,对外同时支持 SSE 监听(接收后端推送)和 REST 请求(主动发送操作):
后端检测到文档内容变更
↓
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 建立 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)
| 链路 | 方式 | 原因 |
|---|---|---|
| mod-chat ↔ Backend | REST + SSE | 自有平台,WS Client 通过 SSE 接收后端推送;REST 发送操作请求 |
| oil-agent ↔ Backend | REST + WebSocket | 外部平台,SSE 可能不被支持;WebSocket 双向通信兼容性强 |
| 存储 | 内容 | 备注 |
|---|---|---|
| PostgreSQL | 文档内容、文档元数据、模板元数据 | 阶段 1 新增模板表 |
| Redis | Token 会话(edit_token、edit_session) | 阶段 2 引入,短效 KV,不落盘 |
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();
}
}
// vite.config.ts(编辑器打包为远程模块,供两个平台加载)
federation({
name: 'editorModule',
filename: 'remoteEntry.js',
exposes: {
'./DocumentEditor': './src/components/editor/DocumentEditor',
},
shared: ['react', 'react-dom'],
})
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(前端构建)
目标:完善编辑体验,支持版本管理,按需启用多人协同。
新增依赖:Yjs、y-websocket、S3/MinIO(大文档迁移)、API Gateway
┌───────────────────────┐ ┌───────────────────────┐
│ 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 增量(不是全量内容),服务端合并所有客户端的增量后广播,冲突由算法自动解决,无需人工干预。
阶段 3 引入 S3/MinIO 后,超过 200KB 的文档内容从 PostgreSQL text 字段迁移到对象存储:
documents 表
content → NULL(大文档不再存 DB)
content_key → "documents/doc-abc123.md" (S3 对象路径)
读取时按 content_key 从 S3 拉取,写入时上传到 S3 再更新 content_key。
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
);
| 组件 | 技术 | 引入阶段 |
|---|---|---|
| 前端框架 | 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 |
| 指标 | 目标值 |
|---|---|
| 编辑器首次加载 | < 2s |
| 文档保存响应 | < 500ms |
| DOCX 导出生成 | < 3s |
| 10 万字文档渲染 | 流畅(60fps) |
| 并发协同用户 | 50 人/文档(阶段 3) |
文档版本: v5.0 最后更新: 2026-06-18 维护者: Axonix 前端团队