Files
leaudit-platform-frontend/docs/minio-file-copy-implementation.md
T
2025-12-05 00:09:32 +08:00

9.0 KiB
Raw Blame History

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

请求参数

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  // 传递复制后的路径
    })
  });

  // ...
};

推荐实施步骤

阶段一:使用模式一(已完成)

  • 直接使用模板文件路径
  • 无需复制文件
  • 快速上线验证功能

阶段二:实现文件复制(按需)

如果发现模式一存在编辑冲突问题,再实施:

  1. 安装 minio 依赖
  2. 创建 app/api/minio-client.server.ts
  3. 实现 api.files.copy.tsx 接口
  4. 前端修改为方式二流程
  5. 测试文件复制功能

性能优化建议

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 接口和数据结构已预留位置,实施成本低。