9.0 KiB
9.0 KiB
MinIO 文件复制接口实现指南
概述
在合同起草功能中,有两种使用模式:
模式一:直接使用模板文件(推荐)
适用场景:模板文档已包含占位符,直接在原模板上编辑
流程:
- 用户点击"起草合同"
- 创建草稿记录,
file_path使用模板文件路径 - 在 Collabora 中编辑模板文件
- 替换占位符完成起草
优点:
- 无需复制文件,速度快
- 节省存储空间
- 实现简单
缺点:
- 多个草稿共用一个模板文件
- Collabora 编辑可能互相干扰
模式二:复制模板文件(完整隔离)
适用场景:需要完全隔离的草稿文件,避免编辑冲突
流程:
- 用户点击"起草合同"
- 前端调用
/api/files/copy复制模板文件 - 创建草稿记录,
file_path使用复制后的文件路径 - 在 Collabora 中编辑新文件
- 替换占位符完成起草
优点:
- 每个草稿独立文件,互不干扰
- 保留原始模板不被修改
缺点:
- 需要复制文件,增加存储空间
- 复制操作增加响应时间
文件复制接口实现
API 端点
POST /api/files/copy
请求参数
interface CopyFileRequest {
sourceFilePath: string; // 源文件路径(模板文件)
targetFilePath: string; // 目标文件路径(草稿文件)
bucket?: string; // MinIO bucket名称(默认:docauditai)
}
响应
interface CopyFileResponse {
success: boolean;
targetFilePath: string; // 复制后的文件路径
message?: string;
}
MinIO SDK 实现
1. 安装依赖
npm install minio
2. 配置环境变量
在 .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
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
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
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
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 // 传递复制后的路径
})
});
// ...
};
推荐实施步骤
阶段一:使用模式一(已完成)
- ✅ 直接使用模板文件路径
- ✅ 无需复制文件
- ✅ 快速上线验证功能
阶段二:实现文件复制(按需)
如果发现模式一存在编辑冲突问题,再实施:
- 安装
minio依赖 - 创建
app/api/minio-client.server.ts - 实现
api.files.copy.tsx接口 - 前端修改为方式二流程
- 测试文件复制功能
性能优化建议
1. 异步复制
文件复制可以异步进行:
// 前端先创建草稿记录,后台异步复制文件
const draft = await createDraft();
// 后台任务队列处理文件复制
backgroundCopyFile(template.file_path, draftFilePath);
2. 延迟复制
只在用户首次编辑时才复制文件:
// 创建草稿时不复制
// 用户首次打开编辑时才触发复制
// 草稿记录中添加 is_copied 字段标记
3. 缓存优化
对于热门模板,预先复制多个副本到缓存池。
清理策略
定期清理未完成的草稿文件
-- 创建定时任务清理7天前的草稿文件
DELETE FROM drafted_contracts
WHERE status = 'draft'
AND created_at < NOW() - INTERVAL '7 days';
删除草稿时同时删除文件
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. 路径验证
// 防止路径遍历攻击
function validateFilePath(filePath: string): boolean {
// 不允许 ../ 或绝对路径
if (filePath.includes('..') || filePath.startsWith('/')) {
return false;
}
return true;
}
2. 用户权限验证
// 只能复制有权限的模板文件
// 只能访问自己的草稿文件
3. 文件大小限制
// 限制可复制的文件大小
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
总结
当前实现采用模式一(直接使用模板),满足基本需求且实现简单。
如果后续发现需要完全隔离的草稿文件,可以按照本文档实施文件复制功能。
API 接口和数据结构已预留位置,实施成本低。