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

743 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 生成规则
```typescript
// 后端生成 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内网连接
```bash
# 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 层严格限制文件大小
```typescript
// 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 安装依赖
```bash
cd backend
npm install ali-oss
npm install -D @types/ali-oss
```
#### 1.2 完善 StorageAdapter 接口
```typescript
// 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
```typescript
// 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
```typescript
// common/storage/LocalAdapter.ts
getSignedUrl(key: string, expires?: number): string {
// 本地开发环境直接返回 URL无需签名
return this.getUrl(key);
}
```
#### 1.5 更新 StorageFactory
```typescript
// 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 小时)
覆盖 **PKB**、**Tool C**、**RVW**、**ASL** 四个核心模块。
#### 2.1 PKB 文档上传
```typescript
// 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 数据清洗(临时文件)
```typescript
// 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 审稿报告(持久存储)
```typescript
// 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 文献导入
```typescript
// 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
```prisma
// 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
```typescript
// 后端:通用文件下载接口
// 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}>` |
| **其他** | 直接下载 | 触发浏览器下载 |
```typescript
// 前端:文件预览/下载组件
// 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 错误处理
```typescript
// 前端:签名 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 本地开发环境
```bash
# .env.development
STORAGE_TYPE=local
LOCAL_STORAGE_DIR=uploads
LOCAL_STORAGE_URL=http://localhost:3001/uploads
```
#### 3.2 阿里云生产环境
```bash
# 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. **创建 Bucket**`ai-clinical-data`区域选北京ACL 私有
2. **CORS 配置**
- 来源:`*`
- 允许 Methods`GET`
- 允许 Headers`*`
3. **生命周期规则**
- 前缀:`temp/`
- 规则1 天后删除
---
## 5. 测试清单
### 5.1 本地测试
```bash
# 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 测试(需要阿里云账号)
```bash
# 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`(稿件+报告存储)
- [ ] 添加 `sourceStorageKey``reportStorageKey` 字段
- [ ] 端到端测试(上传稿件 → 生成报告 → 下载报告)
**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. 相关文档
- [OSS存储架构规划与最佳实践 V4.2](./OSS存储架构规划与最佳实践V4.2.md) - 详细架构设计
- [OSS存储架构规划与最佳实践 V5](./OSS存储架构规划与最佳实践%20V5.md) - 极简版设计
- [云原生开发规范](../../04-开发规范/08-云原生开发规范.md) - 存储抽象层说明
---
*文档结束。实施前请确认所有红线规则已理解。*