# 合同起草功能架构说明 v2.0 ## 架构调整 根据实际需求,重新设计了服务端职责和文件处理策略。 --- ## 核心理念 ### 模板文档已包含占位符 **前提条件**: - 模板文档已在 MinIO 上存在 - 文档中已包含约定格式的占位符(如 `{{甲方名称}}`) - 占位符配置已存储在 `contract_templates.placeholder_schema` **因此**: - 服务端主要负责数据管理(草稿记录、占位符值) - 文件操作交给专门的接口处理(可选) - 前端直接与 Collabora 和 MinIO 交互 --- ## 服务端职责 ### ✅ 核心职责 1. **草稿记录管理** - 创建草稿记录(`drafted_contracts` 表) - 记录占位符填写的值(`placeholder_values` 字段) - 更新草稿状态(draft → completed → archived) 2. **数据查询服务** - 查询用户的草稿列表 - 查询草稿详情 - 查询模板信息 3. **业务逻辑处理** - 必填字段验证 - 权限检查(只能操作自己的草稿) - 状态流转控制 ### ❌ 非职责 1. ~~文件复制~~(由专门的文件服务接口处理) 2. ~~MinIO 直接操作~~(封装在独立模块) 3. ~~文档渲染~~(Collabora Online) 4. ~~占位符替换~~(前端调用 Collabora API) --- ## 文件处理策略 ### 策略一:直接使用模板文件(默认) **适用场景**: - 快速上线,简单实现 - 用户访问量不高,编辑冲突概率低 - 节省存储空间 **实现方式**: ```typescript // 创建草稿时,file_path 直接使用模板路径 const draft = await createDraftContract({ templateId: 123, title: '采购合同', // 不传 draftFilePath }, userId); // draft.file_path = template.file_path ``` **工作流程**: 1. 用户点击"起草合同" 2. 创建草稿记录,`file_path` = 模板路径 3. 用户在 Collabora 中编辑模板文件 4. 替换占位符 5. 保存草稿(保存 placeholder_values) 6. 完成起草 **优点**: - ✅ 无需复制文件,速度快 - ✅ 节省存储空间 - ✅ 实现简单 **缺点**: - ❌ 多个用户同时编辑同一模板可能冲突 - ❌ Collabora 编辑会话可能互相干扰 --- ### 策略二:复制模板文件(可选) **适用场景**: - 需要完全隔离的草稿文件 - 多用户并发编辑同一模板 - 需要保留编辑历史 **实现方式**: ```typescript // 1. 先调用文件复制接口 const copyResponse = await fetch('/api/files/copy', { method: 'POST', body: JSON.stringify({ sourceFilePath: template.file_path, targetFilePath: 'drafts/contract_123_1_1704355200000.docx' }) }); // 2. 创建草稿记录时传递复制后的路径 const draft = await createDraftContract({ templateId: 123, title: '采购合同', draftFilePath: 'drafts/contract_123_1_1704355200000.docx' }, userId); ``` **工作流程**: 1. 用户点击"起草合同" 2. 前端调用 `/api/files/copy` 复制文件 3. 创建草稿记录,`file_path` = 复制后的路径 4. 用户在 Collabora 中编辑新文件 5. 替换占位符 6. 完成起草 **优点**: - ✅ 每个草稿独立文件,互不干扰 - ✅ 保留原始模板不被修改 - ✅ 可以保留编辑历史 **缺点**: - ❌ 需要复制文件,增加存储空间 - ❌ 复制操作增加响应时间 --- ## 数据流架构 ### 创建草稿流程 ``` 用户操作 ↓ 前端:contract-template.detail.$id.tsx ├─ 用户点击"起草合同" ├─ 输入标题 ├─ [可选] 调用 /api/files/copy 复制文件 └─ 调用 /api/contracts/draft 创建草稿 ↓ 后端:api.contracts.draft.tsx ├─ 验证用户身份 ├─ 解析参数(templateId, title, draftFilePath?) └─ 调用 createDraftContract ↓ 服务层:draft-service.server.ts ├─ 查询模板信息 ├─ 确定文件路径(draftFilePath || template.file_path) └─ 插入 drafted_contracts 记录 ↓ 数据库:drafted_contracts 表 └─ 返回新记录 ↓ 前端:跳转到 /contract-draft/:draftId ``` ### 起草页面流程 ``` 起草页面加载 ↓ contract-draft.$draftId.tsx ├─ Loader:加载草稿和模板数据 │ ├─ 查询 drafted_contracts │ └─ 查询 contract_templates └─ 渲染页面 ├─ 左侧:FilePreview 组件 │ └─ CollaboraViewer(编辑模式) │ └─ 加载 draft.file_path 文件 └─ 右侧:PlaceholderForm 组件 └─ 渲染表单字段(基于 template.placeholder_schema) 用户填写表单 ↓ 用户点击"一键替换" ↓ handleBatchReplace() ├─ 遍历 placeholderValues ├─ 调用 collaboraRef.unoCommands.replaceAll() │ └─ 发送 UNO 命令到 Collabora iframe └─ 自动调用 handleSaveDraft() 用户点击"保存草稿" ↓ handleSaveDraft() └─ PUT /api/contracts/draft/:id/placeholders └─ 更新 placeholder_values 字段 用户点击"完成起草" ↓ handleComplete() ├─ 验证必填字段 ├─ POST /api/contracts/draft/:id/complete │ └─ 更新 status = 'completed' └─ 跳转回模板列表 ``` --- ## API 接口设计 ### 1. 创建草稿 **端点**:`POST /api/contracts/draft` **请求**: ```json { "templateId": 123, "title": "采购合同-20250104", "draftFilePath": "drafts/contract_123_1_1704355200000.docx" // 可选 } ``` **处理逻辑**: 1. 验证用户身份 2. 查询模板信息 3. 确定文件路径: - 如果传了 `draftFilePath`,使用它 - 否则使用 `template.file_path` 4. 创建草稿记录 5. 返回草稿ID和路径 **响应**: ```json { "id": 456, "filePath": "drafts/contract_123_1_1704355200000.docx", "title": "采购合同-20250104", "templateId": 123 } ``` ### 2. 文件复制(预留接口) **端点**:`POST /api/files/copy` **请求**: ```json { "sourceFilePath": "templates/contract_template.docx", "targetFilePath": "drafts/contract_123_1_1704355200000.docx", "bucket": "docauditai" } ``` **实现状态**: - ✅ API 接口已创建 - ⏳ MinIO 复制逻辑待实现(预留位置) - 📝 实现指南:`docs/minio-file-copy-implementation.md` ### 3. 更新占位符 **端点**:`PUT /api/contracts/draft/:id/placeholders` **请求**: ```json { "placeholders": { "甲方名称": "广东省烟草专卖局", "合同金额": "500000" } } ``` ### 4. 完成起草 **端点**:`POST /api/contracts/draft/:id/complete` **处理逻辑**: 1. 验证用户权限 2. 更新 `status = 'completed'` 3. 更新 `updated_at` --- ## 数据库设计 ### drafted_contracts 表 ```sql CREATE TABLE drafted_contracts ( id SERIAL PRIMARY KEY, template_id INTEGER NOT NULL, -- 关联的模板ID file_path TEXT NOT NULL, -- 文件路径(可能是模板路径或复制后的路径) title TEXT NOT NULL, -- 合同标题 placeholder_values JSONB DEFAULT '{}', -- 占位符填写的值 status TEXT DEFAULT 'draft', -- draft | completed | archived created_by INTEGER, -- 创建人ID created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); ``` **字段说明**: - `file_path`: - 策略一:值为 `template.file_path`(如 `templates/contract_template.docx`) - 策略二:值为复制后的路径(如 `drafts/contract_123_1_1704355200000.docx`) - `placeholder_values`: - 存储用户填写的占位符值 - JSONB 格式,便于查询和更新 - 示例:`{"甲方名称": "广东省烟草专卖局", "合同金额": "500000"}` --- ## 前端实现 ### 模板详情页 **文件**:`app/routes/contract-template.detail.$id.tsx` **关键代码**: ```typescript const handleStartDraft = async () => { // 提示用户输入标题 const title = prompt('请输入合同标题:', defaultTitle); if (!title) return; // 调用创建草稿 API(策略一:不传 draftFilePath) const response = await fetch('/api/contracts/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ templateId: template.id, title: title.trim() // 不传 draftFilePath,使用模板路径 }) }); const data = await response.json(); // 跳转到起草页面 navigate(`/contract-draft/${data.id}`); }; ``` ### 起草页面 **文件**:`app/routes/contract-draft.$draftId.tsx` **布局**: ```tsx