409 lines
9.0 KiB
Markdown
409 lines
9.0 KiB
Markdown
# MinIO 文件复制接口实现指南
|
||
|
||
## 概述
|
||
|
||
在合同起草功能中,有两种使用模式:
|
||
|
||
### 模式一:直接使用模板文件(推荐)
|
||
|
||
**适用场景**:模板文档已包含占位符,直接在原模板上编辑
|
||
|
||
**流程**:
|
||
1. 用户点击"起草合同"
|
||
2. 创建草稿记录,`file_path` 使用模板文件路径
|
||
3. 在 Collabora 中编辑模板文件
|
||
4. 替换占位符完成起草
|
||
|
||
**优点**:
|
||
- 无需复制文件,速度快
|
||
- 节省存储空间
|
||
- 实现简单
|
||
|
||
**缺点**:
|
||
- 多个草稿共用一个模板文件
|
||
- Collabora 编辑可能互相干扰
|
||
|
||
### 模式二:复制模板文件(完整隔离)
|
||
|
||
**适用场景**:需要完全隔离的草稿文件,避免编辑冲突
|
||
|
||
**流程**:
|
||
1. 用户点击"起草合同"
|
||
2. 前端调用 `/api/files/copy` 复制模板文件
|
||
3. 创建草稿记录,`file_path` 使用复制后的文件路径
|
||
4. 在 Collabora 中编辑新文件
|
||
5. 替换占位符完成起草
|
||
|
||
**优点**:
|
||
- 每个草稿独立文件,互不干扰
|
||
- 保留原始模板不被修改
|
||
|
||
**缺点**:
|
||
- 需要复制文件,增加存储空间
|
||
- 复制操作增加响应时间
|
||
|
||
---
|
||
|
||
## 文件复制接口实现
|
||
|
||
### API 端点
|
||
|
||
```
|
||
POST /api/files/copy
|
||
```
|
||
|
||
### 请求参数
|
||
|
||
```typescript
|
||
interface CopyFileRequest {
|
||
sourceFilePath: string; // 源文件路径(模板文件)
|
||
targetFilePath: string; // 目标文件路径(草稿文件)
|
||
bucket?: string; // MinIO bucket名称(默认:docauditai)
|
||
}
|
||
```
|
||
|
||
### 响应
|
||
|
||
```typescript
|
||
interface CopyFileResponse {
|
||
success: boolean;
|
||
targetFilePath: string; // 复制后的文件路径
|
||
message?: string;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## MinIO SDK 实现
|
||
|
||
### 1. 安装依赖
|
||
|
||
```bash
|
||
npm install minio
|
||
```
|
||
|
||
### 2. 配置环境变量
|
||
|
||
在 `.env` 文件中添加:
|
||
|
||
```env
|
||
# MinIO 配置
|
||
MINIO_ENDPOINT=10.76.244.156
|
||
MINIO_PORT=9000
|
||
MINIO_ACCESS_KEY=your_access_key
|
||
MINIO_SECRET_KEY=your_secret_key
|
||
MINIO_BUCKET=docauditai
|
||
```
|
||
|
||
### 3. 创建 MinIO 客户端工具
|
||
|
||
**文件**:`app/api/minio-client.server.ts`
|
||
|
||
```typescript
|
||
import { Client } from 'minio';
|
||
|
||
let minioClient: Client | null = null;
|
||
|
||
/**
|
||
* 获取 MinIO 客户端实例(单例)
|
||
*/
|
||
export function getMinioClient(): Client {
|
||
if (!minioClient) {
|
||
minioClient = new Client({
|
||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||
useSSL: false,
|
||
accessKey: process.env.MINIO_ACCESS_KEY || '',
|
||
secretKey: process.env.MINIO_SECRET_KEY || ''
|
||
});
|
||
}
|
||
return minioClient;
|
||
}
|
||
|
||
/**
|
||
* 复制 MinIO 对象
|
||
* @param bucket Bucket 名称
|
||
* @param sourceFilePath 源文件路径
|
||
* @param targetFilePath 目标文件路径
|
||
*/
|
||
export async function copyMinioFile(
|
||
bucket: string,
|
||
sourceFilePath: string,
|
||
targetFilePath: string
|
||
): Promise<void> {
|
||
const client = getMinioClient();
|
||
|
||
// 1. 检查源文件是否存在
|
||
try {
|
||
await client.statObject(bucket, sourceFilePath);
|
||
} catch (error) {
|
||
throw new Error(`源文件不存在: ${sourceFilePath}`);
|
||
}
|
||
|
||
// 2. 复制对象
|
||
await client.copyObject(
|
||
bucket, // 目标 bucket
|
||
targetFilePath, // 目标路径
|
||
`/${bucket}/${sourceFilePath}`, // 源路径
|
||
null // 复制条件(可选)
|
||
);
|
||
|
||
console.log(`[MinIO] 文件复制成功: ${sourceFilePath} -> ${targetFilePath}`);
|
||
}
|
||
|
||
/**
|
||
* 检查文件是否存在
|
||
* @param bucket Bucket 名称
|
||
* @param filePath 文件路径
|
||
*/
|
||
export async function fileExists(
|
||
bucket: string,
|
||
filePath: string
|
||
): Promise<boolean> {
|
||
const client = getMinioClient();
|
||
try {
|
||
await client.statObject(bucket, filePath);
|
||
return true;
|
||
} catch (error) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除文件
|
||
* @param bucket Bucket 名称
|
||
* @param filePath 文件路径
|
||
*/
|
||
export async function deleteMinioFile(
|
||
bucket: string,
|
||
filePath: string
|
||
): Promise<void> {
|
||
const client = getMinioClient();
|
||
await client.removeObject(bucket, filePath);
|
||
console.log(`[MinIO] 文件删除成功: ${filePath}`);
|
||
}
|
||
```
|
||
|
||
### 4. 更新文件复制 API
|
||
|
||
**文件**:`app/routes/api.files.copy.tsx`
|
||
|
||
```typescript
|
||
import { copyMinioFile } from '~/api/minio-client.server';
|
||
|
||
export async function action({ request }: ActionFunctionArgs) {
|
||
// ... 验证用户身份 ...
|
||
|
||
const body = await request.json() as CopyFileRequest;
|
||
const { sourceFilePath, targetFilePath, bucket = 'docauditai' } = body;
|
||
|
||
try {
|
||
// 调用 MinIO 复制函数
|
||
await copyMinioFile(bucket, sourceFilePath, targetFilePath);
|
||
|
||
const response: CopyFileResponse = {
|
||
success: true,
|
||
targetFilePath,
|
||
message: '文件复制成功'
|
||
};
|
||
|
||
return json(response);
|
||
} catch (error) {
|
||
console.error('[Files Copy API] 文件复制失败:', error);
|
||
return json(
|
||
{
|
||
success: false,
|
||
error: error instanceof Error ? error.message : '文件复制失败'
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 前端集成
|
||
|
||
### 方式一:直接使用模板(当前实现)
|
||
|
||
**文件**:`app/routes/contract-template.detail.$id.tsx`
|
||
|
||
```typescript
|
||
const handleStartDraft = async () => {
|
||
// 不传 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
|
||
})
|
||
});
|
||
|
||
// ...
|
||
};
|
||
```
|
||
|
||
### 方式二:复制文件后创建草稿
|
||
|
||
**文件**:`app/routes/contract-template.detail.$id.tsx`
|
||
|
||
```typescript
|
||
const handleStartDraft = async () => {
|
||
// 1. 生成草稿文件路径
|
||
const timestamp = Date.now();
|
||
const fileExtension = template.file_path.split('.').pop();
|
||
const targetFilePath = `drafts/contract_${template.id}_${userId}_${timestamp}.${fileExtension}`;
|
||
|
||
// 2. 调用文件复制 API
|
||
const copyResponse = await fetch('/api/files/copy', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
sourceFilePath: template.file_path,
|
||
targetFilePath: targetFilePath
|
||
})
|
||
});
|
||
|
||
if (!copyResponse.ok) {
|
||
throw new Error('文件复制失败');
|
||
}
|
||
|
||
// 3. 创建草稿记录(传递复制后的文件路径)
|
||
const draftResponse = await fetch('/api/contracts/draft', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
templateId: template.id,
|
||
title: title.trim(),
|
||
draftFilePath: targetFilePath // 传递复制后的路径
|
||
})
|
||
});
|
||
|
||
// ...
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 推荐实施步骤
|
||
|
||
### 阶段一:使用模式一(已完成)
|
||
|
||
- ✅ 直接使用模板文件路径
|
||
- ✅ 无需复制文件
|
||
- ✅ 快速上线验证功能
|
||
|
||
### 阶段二:实现文件复制(按需)
|
||
|
||
如果发现模式一存在编辑冲突问题,再实施:
|
||
|
||
1. 安装 `minio` 依赖
|
||
2. 创建 `app/api/minio-client.server.ts`
|
||
3. 实现 `api.files.copy.tsx` 接口
|
||
4. 前端修改为方式二流程
|
||
5. 测试文件复制功能
|
||
|
||
---
|
||
|
||
## 性能优化建议
|
||
|
||
### 1. 异步复制
|
||
|
||
文件复制可以异步进行:
|
||
|
||
```typescript
|
||
// 前端先创建草稿记录,后台异步复制文件
|
||
const draft = await createDraft();
|
||
|
||
// 后台任务队列处理文件复制
|
||
backgroundCopyFile(template.file_path, draftFilePath);
|
||
```
|
||
|
||
### 2. 延迟复制
|
||
|
||
只在用户首次编辑时才复制文件:
|
||
|
||
```typescript
|
||
// 创建草稿时不复制
|
||
// 用户首次打开编辑时才触发复制
|
||
// 草稿记录中添加 is_copied 字段标记
|
||
```
|
||
|
||
### 3. 缓存优化
|
||
|
||
对于热门模板,预先复制多个副本到缓存池。
|
||
|
||
---
|
||
|
||
## 清理策略
|
||
|
||
### 定期清理未完成的草稿文件
|
||
|
||
```sql
|
||
-- 创建定时任务清理7天前的草稿文件
|
||
DELETE FROM drafted_contracts
|
||
WHERE status = 'draft'
|
||
AND created_at < NOW() - INTERVAL '7 days';
|
||
```
|
||
|
||
### 删除草稿时同时删除文件
|
||
|
||
```typescript
|
||
export async function deleteDraft(draftId: number, userId: number) {
|
||
// 1. 查询草稿记录
|
||
const draft = await getDraftById(draftId, userId);
|
||
|
||
// 2. 如果是独立文件(不是模板路径),删除文件
|
||
if (draft.file_path.startsWith('drafts/')) {
|
||
await deleteMinioFile('docauditai', draft.file_path);
|
||
}
|
||
|
||
// 3. 删除数据库记录
|
||
await db.from('drafted_contracts').delete().eq('id', draftId);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 安全考虑
|
||
|
||
### 1. 路径验证
|
||
|
||
```typescript
|
||
// 防止路径遍历攻击
|
||
function validateFilePath(filePath: string): boolean {
|
||
// 不允许 ../ 或绝对路径
|
||
if (filePath.includes('..') || filePath.startsWith('/')) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
```
|
||
|
||
### 2. 用户权限验证
|
||
|
||
```typescript
|
||
// 只能复制有权限的模板文件
|
||
// 只能访问自己的草稿文件
|
||
```
|
||
|
||
### 3. 文件大小限制
|
||
|
||
```typescript
|
||
// 限制可复制的文件大小
|
||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||
```
|
||
|
||
---
|
||
|
||
## 总结
|
||
|
||
当前实现采用**模式一(直接使用模板)**,满足基本需求且实现简单。
|
||
|
||
如果后续发现需要完全隔离的草稿文件,可以按照本文档实施**文件复制功能**。
|
||
|
||
API 接口和数据结构已预留位置,实施成本低。
|