feat(storage): integrate Alibaba Cloud OSS for file persistence - Add OSSAdapter and LocalAdapter with StorageFactory pattern - Integrate PKB module with OSS upload - Rename difyDocumentId to storageKey - Create 4 OSS buckets and development specification

This commit is contained in:
2026-01-22 22:02:20 +08:00
parent 483c62fb6f
commit 9c96f75c52
309 changed files with 4583 additions and 172 deletions

View File

@@ -0,0 +1,742 @@
# 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) - 存储抽象层说明
---
*文档结束。实施前请确认所有红线规则已理解。*