Files
AIclinicalresearch/docs/01-平台基础层/02-存储服务/OSS存储实施方案-MVP版.md

20 KiB
Raw Permalink Blame History

OSS 存储实施方案 - MVP版

文档版本: v1.0
创建日期: 2026-01-22
状态: 待实施
适用团队: 2人开发团队


1. 背景与目标

1.1 当前问题

问题 影响 严重程度
OSSAdapter 未实现 无法部署到阿里云 🔴
PKB 文件未持久化 原始文件丢失,无法重新处理 🔴
ali-oss 依赖未安装 OSS 代码无法运行 🔴
缺少签名 URL 方法 私有文件无法安全访问 🟡

1.2 实施目标

  1. 完成 OSSAdapter 实现 - 支持本地/云端无缝切换
  2. PKB 文件持久化 - 上传时存储原始文件到 OSS
  3. 开发环境可测试 - 本地开发使用 LocalAdapter云端使用 OSSAdapter

2. 架构决策

2.1 核心原则

┌─────────────────────────────────────────────────────────────┐
│                    MVP 极简原则                              │
├─────────────────────────────────────────────────────────────┤
│  ✅ 后端统一管控:前端只提交 FormData不接触 OSS           │
│  ✅ 工厂模式切换STORAGE_TYPE 环境变量控制本地/云端        │
│  ✅ 串行处理优先:先存储再处理,简单可靠                    │
│  ❌ 不做过早优化:不搞分层存储、不搞归档、不搞双流分发      │
└─────────────────────────────────────────────────────────────┘

2.2 Bucket 规划

保持 4 Bucket 物理隔离(安全底线):

环境 Bucket ACL 用途
生产 ai-clinical-data 私有 核心数据(文献、病历、统计结果)
生产 ai-clinical-static 公共读 静态资源头像、Logo、RAG图片
开发 ai-clinical-data-dev 私有 开发测试
开发 ai-clinical-static-dev 公共读 开发测试

2.3 目录结构

基于现有租户模型,统一使用以下结构:

# 1. 用户私有数据 (User-Level Data)
# 逻辑:归属于特定租户下的特定用户
tenants/{tenantId}/users/{userId}/{module}/{uuid}.{ext}

# 示例
tenants/t001/users/u123/pkb/a1b2c3d4.pdf      # PKB 文献(个人)
tenants/t001/users/u123/asl/e5f6g7h8.pdf      # ASL 文献(个人)
tenants/t001/users/u123/rvw/i9j0k1l2.docx     # RVW 稿件(个人)
tenants/t001/users/u123/ssa/m3n4o5p6.xlsx     # SSA 统计数据(个人)

# 2. 租户共享数据 (Tenant-Level Shared Data)
# 逻辑:归属于租户,通常由管理员上传或全员共享
tenants/{tenantId}/shared/{module}/{uuid}.{ext}

# 示例
tenants/t001/shared/ekb/q7r8s9t0.pdf          # 租户知识库 EKB全员共享
tenants/t001/shared/emr/u1v2w3x4.json         # 原始病历数据 EMR机构数据
tenants/t001/shared/templates/y5z6a7b8.docx   # 机构模板文件

# 3. 临时文件OSS 生命周期 1 天自动删除)
temp/{date}/{uuid}.{ext}

# 示例
temp/20260122/c3d4e5f6.xlsx                   # Tool C 上传 / ASL 导入

# 4. 系统文件(平台级资源)
system/{category}/{filename}

# 示例
system/templates/gcp_guide.pdf                # 系统预置模板
system/samples/demo_data.xlsx                 # 演示数据

2.4 Key 生成规则

// 后端生成 Key 的统一方法
function generateStorageKey(
  tenantId: string,
  userId: string | null,  // null 表示租户共享
  module: string,
  filename: string
): string {
  const uuid = generateUUID();
  const ext = path.extname(filename);
  
  if (userId) {
    // 用户私有数据
    return `tenants/${tenantId}/users/${userId}/${module}/${uuid}${ext}`;
  } else {
    // 租户共享数据
    return `tenants/${tenantId}/shared/${module}/${uuid}${ext}`;
  }
}

// 使用示例
const key1 = generateStorageKey('t001', 'u123', 'pkb', 'paper.pdf');
// → tenants/t001/users/u123/pkb/a1b2c3d4.pdf

const key2 = generateStorageKey('t001', null, 'ekb', 'guideline.pdf');
// → tenants/t001/shared/ekb/q7r8s9t0.pdf

3. 红线规则(修订版)

🔴 红线 1内网连接

# SAE 生产环境必须配置内网 Endpoint
OSS_ENDPOINT=oss-cn-beijing-internal.aliyuncs.com

# 本地开发使用公网 Endpoint
OSS_ENDPOINT=oss-cn-beijing.aliyuncs.com

违反后果:流量费用暴增,大文件上传卡死

🔴 红线 2私有存储

ai-clinical-data Bucket 必须设置为 Private
绝对不能为了测试方便而开放公共读

违反后果:医疗数据泄露,合规风险

🟡 红线 3内存安全分层策略

文件大小 处理方式 说明
< 30MB toBuffer() MVP 阶段可用,简单优先
30-50MB 两者皆可 视场景选择
> 50MB stream.pipeline 🚨 必须用流式,否则 OOM

关键:在 Fastify 层严格限制文件大小

// server.ts
fastify.register(multipart, {
  limits: {
    fileSize: 30 * 1024 * 1024  // 30MB 硬限制
  }
});

4. 实施计划

时间预估总览

Phase 内容 预计时间
Phase 1 基础设施OSSAdapter 实现) 2 小时
Phase 2 业务模块集成PKB/Tool C/RVW/ASL 3 小时
Phase 2.5 前端消费链路 1 小时
Phase 3 环境配置 30 分钟
总计 6.5 小时

Phase 1基础设施预计 2 小时)

1.1 安装依赖

cd backend
npm install ali-oss
npm install -D @types/ali-oss

1.2 完善 StorageAdapter 接口

// common/storage/StorageAdapter.ts
export interface StorageAdapter {
  // 现有方法
  upload(key: string, buffer: Buffer): Promise<string>
  download(key: string): Promise<Buffer>
  delete(key: string): Promise<void>
  getUrl(key: string): string
  exists(key: string): Promise<boolean>
  
  // 新增方法
  getSignedUrl(key: string, expires?: number): string  // 签名 URL私有文件访问
}

1.3 实现 OSSAdapter

// common/storage/OSSAdapter.ts
import OSS from 'ali-oss';
import { StorageAdapter } from './StorageAdapter.js';

export class OSSAdapter implements StorageAdapter {
  private readonly client: OSS;

  constructor(config: {
    region: string;
    bucket: string;
    accessKeyId: string;
    accessKeySecret: string;
    internal?: boolean;
  }) {
    this.client = new OSS({
      region: config.region,
      bucket: config.bucket,
      accessKeyId: config.accessKeyId,
      accessKeySecret: config.accessKeySecret,
      internal: config.internal ?? false,
    });
  }

  async upload(key: string, buffer: Buffer): Promise<string> {
    const result = await this.client.put(key, buffer);
    return result.url;
  }

  async download(key: string): Promise<Buffer> {
    const result = await this.client.get(key);
    return result.content as Buffer;
  }

  async delete(key: string): Promise<void> {
    await this.client.delete(key);
  }

  getUrl(key: string): string {
    return this.client.generateObjectUrl(key);
  }

  getSignedUrl(key: string, expires: number = 900): string {
    // 默认 15 分钟过期(医疗数据安全考虑)
    return this.client.signatureUrl(key, { expires });
  }

  async exists(key: string): Promise<boolean> {
    try {
      await this.client.head(key);
      return true;
    } catch (error: any) {
      if (error.code === 'NoSuchKey') {
        return false;
      }
      throw error;
    }
  }
}

1.4 更新 LocalAdapter添加 getSignedUrl

// common/storage/LocalAdapter.ts
getSignedUrl(key: string, expires?: number): string {
  // 本地开发环境直接返回 URL无需签名
  return this.getUrl(key);
}

1.5 更新 StorageFactory

// common/storage/StorageFactory.ts
private static createOSSAdapter(): OSSAdapter {
  const region = process.env.OSS_REGION;
  const bucket = process.env.OSS_BUCKET;
  const accessKeyId = process.env.OSS_ACCESS_KEY_ID;
  const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET;
  const internal = process.env.OSS_INTERNAL === 'true';

  if (!region || !bucket || !accessKeyId || !accessKeySecret) {
    throw new Error('[StorageFactory] OSS configuration incomplete');
  }

  return new OSSAdapter({
    region,
    bucket,
    accessKeyId,
    accessKeySecret,
    internal,
  });
}

Phase 2业务模块文件持久化预计 3 小时)

覆盖 PKBTool CRVWASL 四个核心模块。

2.1 PKB 文档上传

// modules/pkb/controllers/documentController.ts
import { storage } from '../../../common/storage/index.js';
import { v4 as uuid } from 'uuid';

export async function uploadDocument(request, reply) {
  const { kbId } = request.params;
  const data = await request.file();
  const buffer = await data.toBuffer();  // 文件 < 30MB可接受
  
  const userId = getUserId(request);
  const tenantId = request.user.tenantId || 'default';
  
  // 生成存储 Key用户私有数据
  const fileId = uuid();
  const ext = path.extname(data.filename);
  const storageKey = `tenants/${tenantId}/users/${userId}/pkb/${fileId}${ext}`;
  
  // 1. 先存储到 OSS/本地
  const fileUrl = await storage.upload(storageKey, buffer);
  
  // 2. 再调用文档服务处理
  const document = await documentService.uploadDocument(
    userId, kbId, buffer, data.filename, data.mimetype,
    buffer.length, fileUrl, storageKey
  );
  
  return reply.status(201).send({ success: true, data: document });
}

2.2 Tool C 数据清洗(临时文件)

// modules/dc/tool-c/services/SessionService.ts
// 当前已使用 storage需确认 Key 格式符合规范

// 上传时使用临时目录1天自动删除
const storageKey = `temp/${formatDate(new Date())}/${uuid()}.xlsx`;
await storage.upload(storageKey, fileBuffer);

// 清洗结果也存临时目录
const cleanDataKey = `temp/${formatDate(new Date())}/${uuid()}_clean.json`;
await storage.upload(cleanDataKey, cleanDataBuffer);

2.3 RVW 审稿报告(持久存储)

// modules/rvw/services/reviewService.ts
// 审稿报告需要持久存储

// 原始稿件
const sourceKey = `tenants/${tenantId}/users/${userId}/rvw/${taskId}/source${ext}`;
await storage.upload(sourceKey, sourceBuffer);

// 审稿报告(生成的 Word
const reportKey = `tenants/${tenantId}/users/${userId}/rvw/${taskId}/report.docx`;
await storage.upload(reportKey, reportBuffer);

2.4 ASL 文献导入

// modules/asl/services/importService.ts

// Excel 导入文件临时1天删除
const tempKey = `temp/${formatDate(new Date())}/${uuid()}.xlsx`;
await storage.upload(tempKey, excelBuffer);

// PDF 文献文件(持久存储)
const pdfKey = `tenants/${tenantId}/users/${userId}/asl/${projectId}/${uuid()}.pdf`;
await storage.upload(pdfKey, pdfBuffer);

2.5 更新数据库 Schema

// schema.prisma

// PKB 文档
model Document {
  // ... 现有字段
  storageKey    String?   // 新增OSS 存储路径
}

// RVW 审稿任务
model ReviewTask {
  // ... 现有字段
  sourceStorageKey  String?   // 原始稿件路径
  reportStorageKey  String?   // 审稿报告路径
}

// ASL 文献
model ASLDocument {
  // ... 现有字段
  pdfStorageKey     String?   // PDF 文件路径
}

Phase 2.5:前端文件消费链路(预计 1 小时)

2.5.1 统一文件下载 API

// 后端:通用文件下载接口
// GET /api/v1/storage/signed-url?key={storageKey}

export async function getSignedUrl(request, reply) {
  const { key } = request.query;
  const userId = getUserId(request);
  
  // 权限校验:确保用户有权访问该文件
  const hasAccess = await checkFileAccess(userId, key);
  if (!hasAccess) {
    return reply.status(403).send({ success: false, message: 'Access denied' });
  }
  
  // 根据文件类型设置不同过期时间
  const ext = path.extname(key).toLowerCase();
  const expires = getExpiresForFileType(ext);
  
  const signedUrl = storage.getSignedUrl(key, expires);
  
  return reply.send({
    success: true,
    data: {
      url: signedUrl,
      filename: path.basename(key),
      expiresIn: expires,
      contentType: getContentType(ext)
    }
  });
}

// 过期时间策略
function getExpiresForFileType(ext: string): number {
  switch (ext) {
    case '.pdf':
      return 3600;      // PDF 预览1小时
    case '.docx':
    case '.xlsx':
      return 300;       // Office 下载5分钟
    default:
      return 900;       // 默认15分钟
  }
}

2.5.2 前端消费策略

文件类型 消费方式 实现方法
PDF 内嵌预览 <iframe src={signedUrl}> 或 PDF.js
Word/Excel 直接下载 window.open(signedUrl)<a download>
图片 内嵌显示 <img src={signedUrl}>
其他 直接下载 触发浏览器下载
// 前端:文件预览/下载组件
// components/FileViewer.tsx

interface FileViewerProps {
  storageKey: string;
  filename: string;
}

export function FileViewer({ storageKey, filename }: FileViewerProps) {
  const [signedUrl, setSignedUrl] = useState<string | null>(null);
  const ext = filename.split('.').pop()?.toLowerCase();

  useEffect(() => {
    // 获取签名 URL
    fetchSignedUrl(storageKey).then(setSignedUrl);
  }, [storageKey]);

  if (!signedUrl) return <Spin />;

  // PDF 内嵌预览
  if (ext === 'pdf') {
    return (
      <iframe 
        src={signedUrl} 
        style={{ width: '100%', height: '80vh' }}
        title={filename}
      />
    );
  }

  // Word/Excel 下载按钮
  if (['docx', 'xlsx', 'doc', 'xls'].includes(ext || '')) {
    return (
      <Button 
        icon={<DownloadOutlined />}
        onClick={() => window.open(signedUrl, '_blank')}
      >
        下载 {filename}
      </Button>
    );
  }

  // 图片显示
  if (['png', 'jpg', 'jpeg', 'gif'].includes(ext || '')) {
    return <img src={signedUrl} alt={filename} style={{ maxWidth: '100%' }} />;
  }

  // 其他文件下载
  return (
    <a href={signedUrl} download={filename}>
      下载 {filename}
    </a>
  );
}

2.5.3 私有文件 403 错误处理

// 前端:签名 URL 过期自动刷新
async function fetchSignedUrl(storageKey: string): Promise<string> {
  try {
    const response = await api.get('/storage/signed-url', {
      params: { key: storageKey }
    });
    return response.data.data.url;
  } catch (error) {
    if (error.response?.status === 403) {
      message.error('文件访问权限不足');
    } else if (error.response?.status === 404) {
      message.error('文件不存在或已被删除');
    }
    throw error;
  }
}

// 前端URL 过期自动重试
function useSignedUrl(storageKey: string, expiresIn: number = 900) {
  const [url, setUrl] = useState<string | null>(null);
  
  useEffect(() => {
    let refreshTimer: NodeJS.Timeout;
    
    const refresh = async () => {
      const newUrl = await fetchSignedUrl(storageKey);
      setUrl(newUrl);
      
      // 在过期前 1 分钟自动刷新
      refreshTimer = setTimeout(refresh, (expiresIn - 60) * 1000);
    };
    
    refresh();
    
    return () => clearTimeout(refreshTimer);
  }, [storageKey, expiresIn]);
  
  return url;
}

Phase 3环境配置预计 30 分钟)

3.1 本地开发环境

# .env.development
STORAGE_TYPE=local
LOCAL_STORAGE_DIR=uploads
LOCAL_STORAGE_URL=http://localhost:3001/uploads

3.2 阿里云生产环境

# SAE 环境变量
STORAGE_TYPE=oss
OSS_REGION=oss-cn-beijing
OSS_BUCKET=ai-clinical-data
OSS_ACCESS_KEY_ID=your-access-key-id
OSS_ACCESS_KEY_SECRET=your-access-key-secret
OSS_INTERNAL=true

3.3 阿里云 OSS 控制台配置

  1. 创建 Bucketai-clinical-data区域选北京ACL 私有
  2. CORS 配置
    • 来源:*
    • 允许 MethodsGET
    • 允许 Headers*
  3. 生命周期规则
    • 前缀:temp/
    • 规则1 天后删除

5. 测试清单

5.1 本地测试

# 1. 启动后端(使用 LocalAdapter
cd backend
npm run dev

# 2. 上传文件测试
curl -X POST http://localhost:3001/api/v1/pkb/knowledge/knowledge-bases/{kbId}/documents \
  -H "Authorization: Bearer {token}" \
  -F "file=@test.pdf"

# 3. 检查文件是否存储
ls backend/uploads/tenants/*/users/*/pkb/

5.2 OSS 测试(需要阿里云账号)

# 1. 配置 OSS 环境变量
export STORAGE_TYPE=oss
export OSS_REGION=oss-cn-beijing
export OSS_BUCKET=ai-clinical-data-dev
export OSS_ACCESS_KEY_ID=xxx
export OSS_ACCESS_KEY_SECRET=xxx
export OSS_INTERNAL=false  # 本地测试用公网

# 2. 运行测试
npm run dev

# 3. 上传文件,检查 OSS 控制台

6. 成本预警

6.1 预算监控

指标 警戒线 行动
OSS 月账单 > 100 元 研究生命周期管理和归档存储
存储量 > 500 GB 清理历史数据,启用低频存储
流量费 > 50 元/月 检查是否误用公网 Endpoint

6.2 阿里云预算告警设置

  1. 登录阿里云控制台 → 费用中心 → 预算管理
  2. 创建预算OSS 服务,月预算 100 元
  3. 告警阈值80%、100%
  4. 通知方式:短信 + 邮件

7. 未来优化(暂不实施)

以下功能在 MVP 阶段明确不做,等业务需求明确后再考虑:

功能 触发条件 复杂度
流式双流分发 (PassThrough) 文件限制放宽到 100MB+
智能分层存储 月账单 > 100 元
归档存储 存储量 > 1TB
断点续传 支持大文件上传
审计日志 有合规审计需求
多区域部署 有异地用户

8. 实施检查清单

Phase 1: 基础设施

  • 安装 ali-oss@types/ali-oss
  • 更新 StorageAdapter 接口(添加 getSignedUrl
  • 实现 OSSAdapter 完整代码
  • 更新 LocalAdapter(添加 getSignedUrl
  • 更新 StorageFactory(添加 internal 参数)
  • 本地测试通过

Phase 2: 业务模块集成

PKB 个人知识库

  • 修改 documentController.ts(添加存储逻辑)
  • 添加 storageKey 字段到 Document 表
  • 端到端测试(上传 → 存储 → RAG 入库)

Tool C 数据清洗

  • 确认 SessionService.ts Key 格式符合 temp/ 规范
  • 验证临时文件正确使用 temp/{date}/ 目录

RVW 审稿系统

  • 修改 reviewService.ts(稿件+报告存储)
  • 添加 sourceStorageKeyreportStorageKey 字段
  • 端到端测试(上传稿件 → 生成报告 → 下载报告)

ASL 智能文献

  • 修改导入服务Excel 存 temp/PDF 存 tenants/
  • 添加 pdfStorageKey 字段到 ASLDocument 表
  • 端到端测试Excel 导入 + PDF 上传)

Phase 2.5: 前端消费链路

  • 实现通用签名 URL API (/api/v1/storage/signed-url)
  • 实现文件访问权限校验
  • 实现 FileViewer 组件PDF 预览/Office 下载)
  • 实现签名 URL 过期自动刷新
  • 403/404 错误处理

Phase 3: 环境配置

  • 配置本地 .env.development
  • 阿里云创建 Bucket4个
  • 阿里云配置 CORS仅 GET
  • 阿里云配置生命周期规则(temp/ 1天删除
  • 阿里云设置成本预警100元/月)
  • SAE 配置环境变量

9. 相关文档


文档结束。实施前请确认所有红线规则已理解。