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:
742
docs/01-平台基础层/02-存储服务/OSS存储实施方案-MVP版.md
Normal file
742
docs/01-平台基础层/02-存储服务/OSS存储实施方案-MVP版.md
Normal 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`
|
||||
- [ ] 阿里云创建 Bucket(4个)
|
||||
- [ ] 阿里云配置 CORS(仅 GET)
|
||||
- [ ] 阿里云配置生命周期规则(`temp/` 1天删除)
|
||||
- [ ] 阿里云设置成本预警(100元/月)
|
||||
- [ ] SAE 配置环境变量
|
||||
|
||||
---
|
||||
|
||||
## 9. 相关文档
|
||||
|
||||
- [OSS存储架构规划与最佳实践 V4.2](./OSS存储架构规划与最佳实践V4.2.md) - 详细架构设计
|
||||
- [OSS存储架构规划与最佳实践 V5](./OSS存储架构规划与最佳实践%20V5.md) - 极简版设计
|
||||
- [云原生开发规范](../../04-开发规范/08-云原生开发规范.md) - 存储抽象层说明
|
||||
|
||||
---
|
||||
|
||||
*文档结束。实施前请确认所有红线规则已理解。*
|
||||
|
||||
148
docs/01-平台基础层/02-存储服务/OSS存储架构规划与最佳实践 V5.md
Normal file
148
docs/01-平台基础层/02-存储服务/OSS存储架构规划与最佳实践 V5.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# **OSS 存储架构规划与最佳实践 (V5.0)**
|
||||
|
||||
**文档状态:** 已发布 (v5.0 \- 极简生存版)
|
||||
|
||||
**适用项目:** AIclinicalresearch
|
||||
|
||||
**核心原则:** **不做过早优化,只做必要隔离**
|
||||
|
||||
**最后更新:** 2026-01-22
|
||||
|
||||
## **1\. 架构核心决策 (The MVP Way)**
|
||||
|
||||
我们摒弃一切复杂的云原生概念,怎么简单怎么来。
|
||||
|
||||
1. **上传方式**:全线 **后端流式上传** (stream.pipeline)。前端只负责 Form Data 提交。
|
||||
2. **权限管理**:SAE 实例绑定 **AliyunOSSFullAccess**。不搞自定义策略。
|
||||
3. **成本策略**:**全标准存储**。不搞分层,不搞归档,直到月账单超过 100 元。
|
||||
|
||||
## **2\. Bucket 物理规划**
|
||||
|
||||
为了**安全底线**(防止生产数据被误删、防止患者数据裸奔),我们保留 **4 个 Bucket** 的设计。这是唯一的坚持。
|
||||
|
||||
| 环境 | Bucket 名称 (示例) | 权限 (ACL) | 说明 |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| **生产** | **ai-clinical-data** | **私有** | 核心数据。**仅配置跨域(CORS)用于GET**。 |
|
||||
| **生产** | **ai-clinical-static** | **公共读** | 头像、Logo、RAG切片图。**配置跨域(CORS)**。 |
|
||||
| **开发** | **ai-clinical-data-dev** | **私有** | 开发测试用。 |
|
||||
| **开发** | **ai-clinical-static-dev** | **公共读** | 开发测试用。 |
|
||||
|
||||
**配置项 (仅需配这 2 项)**:
|
||||
|
||||
1. **CORS**:来源 \*,允许 Methods GET。
|
||||
2. **生命周期**:仅针对 temp/ 目录设置 1 天删除。其他不用管。
|
||||
|
||||
## **3\. 极简目录结构 (Flat & Simple)**
|
||||
|
||||
基于**全员租户化**的管理逻辑,所有数据根目录统一为 tenants/。
|
||||
|
||||
后端生成 Key 的逻辑统一为:tenants/{TenantID}/{Scope}/{Module}/{UUID}.{ext}
|
||||
|
||||
### **3.1 核心数据 Bucket (ai-clinical-data)**
|
||||
|
||||
\# 1\. 用户私有数据 (User-Level Data)
|
||||
\# 逻辑:归属于特定租户下的特定用户
|
||||
\# 结构: tenants/{tenantId}/users/{userId}/{module}/{uuid}.pdf
|
||||
tenants/t999/users/u123/pkb/a1b2c3d4.pdf \# PKB 文献 (个人)
|
||||
tenants/t999/users/u123/asl/e5f6g7h8.pdf \# ASL 文献 (个人)
|
||||
tenants/t999/users/u123/rvw/i9j0k1l2.docx \# RVW 稿件 (个人)
|
||||
tenants/t999/users/u123/ssa/m3n4o5p6.xlsx \# 统计数据 (个人)
|
||||
|
||||
\# 2\. 租户共享数据 (Tenant-Level Shared Data)
|
||||
\# 逻辑:归属于租户,通常由管理员上传或全员共享
|
||||
\# 结构: tenants/{tenantId}/shared/{module}/{uuid}.pdf
|
||||
tenants/t999/shared/ekb/q7r8s9t0.pdf \# 租户知识库 (EKB)
|
||||
tenants/t999/shared/emr/u1v2w3x4.json \# 原始病历数据 (EMR)
|
||||
|
||||
\# 3\. 临时中转区 (唯一配置生命周期的目录)
|
||||
\# 结构: temp/{date}/{uuid}.xlsx
|
||||
temp/20260122/y5z6a7b8.xlsx \# Tool C 上传 / ASL 导入 / 临时导出
|
||||
\# (OSS配置: 1天后自动删除)
|
||||
|
||||
\# 4\. 系统级文件
|
||||
system/templates/gcp\_guide.pdf \# 系统模板
|
||||
|
||||
### **3.2 静态资源 Bucket (ai-clinical-static)**
|
||||
|
||||
\# 1\. RAG 切片图 (混淆文件名)
|
||||
\# 结构: rag/{uuid}.png
|
||||
rag/f9e8d7c6b5a4.png
|
||||
|
||||
\# 2\. 头像
|
||||
avatars/u123.png
|
||||
|
||||
## **4\. 后端实现指南 (Node.js)**
|
||||
|
||||
### **4.1 环境变量 (SAE)**
|
||||
|
||||
不需要复杂的 AK/SK 管理,直接用 SAE 的 RAM 角色(需绑定 AliyunOSSFullAccess)。
|
||||
|
||||
\# 只有这 3 个变量是必须的
|
||||
OSS\_REGION=oss-cn-beijing
|
||||
OSS\_ENDPOINT=oss-cn-beijing-internal.aliyuncs.com \# 内网地址,省流量费
|
||||
OSS\_BUCKET\_DATA=ai-clinical-data
|
||||
|
||||
### **4.2 核心代码 (Common Service)**
|
||||
|
||||
不要过度封装,一个简单的 StorageService 足矣。
|
||||
|
||||
// common/storage/storage.service.ts
|
||||
import OSS from 'ali-oss';
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
// 使用 STS 或 SAE 自动注入的凭证,或者直接配 AK/SK (MVP阶段最简单)
|
||||
const client \= new OSS({
|
||||
region: process.env.OSS\_REGION,
|
||||
accessKeyId: process.env.OSS\_ACCESS\_KEY\_ID, // 简单起见,MVP先用 AK
|
||||
accessKeySecret: process.env.OSS\_ACCESS\_KEY\_SECRET,
|
||||
bucket: process.env.OSS\_BUCKET\_DATA,
|
||||
internal: true, // 开启内网模式
|
||||
});
|
||||
|
||||
export const StorageService \= {
|
||||
// 1\. 流式上传 (核心方法)
|
||||
async uploadStream(key: string, stream: any) {
|
||||
// usePutObject 接口支持 stream
|
||||
return await client.putStream(key, stream);
|
||||
},
|
||||
|
||||
// 2\. 获取临时链接 (私有文件预览)
|
||||
getSignedUrl(key: string) {
|
||||
return client.signatureUrl(key, { expires: 3600 });
|
||||
},
|
||||
|
||||
// 3\. 删除文件
|
||||
async delete(key: string) {
|
||||
return await client.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
### **4.3 业务调用范例**
|
||||
|
||||
// Tool C 上传接口
|
||||
fastify.post('/upload', async (req, reply) \=\> {
|
||||
const data \= await req.file();
|
||||
|
||||
// 极简 Key 生成
|
||||
const key \= \`temp/${Date.now()}/${data.filename}\`;
|
||||
|
||||
// 一行代码上传,无需 try-catch 复杂逻辑 (全局 Error Handler 会捕获)
|
||||
await StorageService.uploadStream(key, data.file);
|
||||
|
||||
return { url: key }; // 返回 Key 即可,前端不需要知道真实 URL
|
||||
});
|
||||
|
||||
## **5\. 2人团队的“生死红线”**
|
||||
|
||||
在极简模式下,只有这 3 条规则必须死守,其他都可以妥协:
|
||||
|
||||
1. **内网连接**:SAE 必须配 \-internal 的 Endpoint。一旦配成公网,上传大文件会卡死且扣费。
|
||||
2. **私有存储**:ai-clinical-data 必须是 **Private**。这是医疗底线,不能为了省代码而裸奔。
|
||||
3. **流式处理**:代码里必须用 pipe / putStream。严禁 await file.toBuffer(),否则 2 人的小服务器一定会内存溢出崩掉。
|
||||
|
||||
## **6\. 成本预警 (Cost Watch)**
|
||||
|
||||
MVP 阶段不配置复杂的归档策略,但需关注一条警戒线:
|
||||
|
||||
* **警戒线**:当 OSS 月账单超过 **100 元** (约 500GB 存储) 时。
|
||||
* **行动**:此时再去研究“生命周期管理”和“归档存储”。在此之前,专注于业务代码。
|
||||
249
docs/01-平台基础层/02-存储服务/OSS存储架构规划与最佳实践V4.2.md
Normal file
249
docs/01-平台基础层/02-存储服务/OSS存储架构规划与最佳实践V4.2.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# **OSS 存储架构规划与最佳实践 (V4.3)**
|
||||
|
||||
**文档状态:** 已发布 (v4.3 \- 含安全红线准则)
|
||||
|
||||
**适用项目:** AIclinicalresearch
|
||||
|
||||
**核心原则:** 后端统一管控、内网高速传输、数据绝对私有
|
||||
|
||||
**最后更新:** 2026-01-21
|
||||
|
||||
## **1\. 总体架构设计**
|
||||
|
||||
鉴于我们\*\*全线采用“后端流式上传”\*\*策略,架构重心从“端到端交互”转移至“服务器端流处理”。
|
||||
|
||||
### **1.1 核心优势**
|
||||
|
||||
* **开发极简**:前端只需普通的 Form Data 上传,无需引入阿里 SDK,无需处理签名。
|
||||
* **权限收敛**:OSS 的写权限仅由后端 Node.js 掌握,前端 0 权限,杜绝 Key 泄露风险。
|
||||
* **处理原子化**:上传 OSS \+ 写入数据库 \+ 触发 AI 解析,这三个动作在后端同一个事务流程中完成,不再有“孤儿文件”问题。
|
||||
|
||||
### **1.2 Bucket 规划矩阵**
|
||||
|
||||
继续沿用 **“四 Bucket 物理隔离”** 策略。
|
||||
|
||||
| 环境 | Bucket 名称 (示例) | 权限 (ACL) | 用途 | 生命周期策略 |
|
||||
| :---- | :---- | :---- | :---- | :---- |
|
||||
| **生产 (Prod)** | **ai-clinical-data** | **私有 (Private)** | **(核心资产)** 存放病历、文献、稿件、统计结果、RAG索引。 | 智能分层 (30天转低频) |
|
||||
| **生产 (Prod)** | **ai-clinical-static** | **公共读 (Public)** | **(静态资源)** 存放系统头像、**Admin上传资源**、RAG解析图片。 | 永久保存 |
|
||||
| **开发 (Dev)** | **ai-clinical-data-dev** | **私有** | **(开发测试)** 存放开发调试产生的垃圾数据。 | **30天自动清空** (防止成本失控) |
|
||||
| **开发 (Dev)** | **ai-clinical-static-dev** | **公共读** | **(开发资源)** 存放开发环境的静态资源。 | 永久保存 |
|
||||
|
||||
### **1.3 关键配置规范 (针对后端流式优化)**
|
||||
|
||||
1. **服务端加密 (Encryption)**:所有 **Data Bucket** 必须开启 **SSE-OSS (AES256)**。
|
||||
2. **跨域 (CORS) \- 仅需读权限**:
|
||||
* **场景**:仅用于前端直接通过 URL (签名或公开) 预览/下载文件。**上传不需要 CORS**。
|
||||
* **来源**:http://localhost:3000 (Dev), https://你的生产域名.com (Prod)
|
||||
* **允许 Methods**:仅需 **GET**, **HEAD**。 (严禁开启 PUT/POST/DELETE)
|
||||
* **允许 Headers**:\*
|
||||
* **暴露 Headers**:ETag, x-oss-request-id
|
||||
3. **内网加速**:
|
||||
* **SAE 环境变量**:必须配置 OSS\_ENDPOINT 为 oss-cn-beijing-internal.aliyuncs.com。
|
||||
* **效果**:SAE \-\> OSS 流量免费且速度极快(千兆级),这是后端流式上传可行的物理基础。
|
||||
|
||||
## **2\. 目录结构设计 (Object Key)**
|
||||
|
||||
即使上传方式改变,清晰的目录结构依然是多租户隔离的基础。
|
||||
|
||||
### **2.1 核心数据目录 (ai-clinical-data / ...-dev)**
|
||||
|
||||
\# 1\. 个人用户 (To C)
|
||||
users/{userId}/pkb/{kbId}/documents/{fileId}.pdf \# PKB 原始文献
|
||||
users/{userId}/asl/projects/{projId}/pdfs/{fileId}.pdf \# ASL 文献PDF
|
||||
users/{userId}/rvw/tasks/{taskId}/source.{ext} \# RVW 原始稿件
|
||||
users/{userId}/rvw/tasks/{taskId}/report.docx \# RVW 审稿报告
|
||||
users/{userId}/dc/tasks/{taskId}/source.xlsx \# Tool B 数据清洗任务
|
||||
|
||||
\# 2\. 统计模块 (SSA/ST)
|
||||
users/{userId}/ssa/projects/{projId}/data/{fileId}.xlsx \# SSA 原始数据
|
||||
users/{userId}/ssa/projects/{projId}/outputs/{runId}/... \# SSA 结果
|
||||
users/{userId}/st/tasks/{taskId}/source.xlsx \# ST 工具数据
|
||||
users/{userId}/st/tasks/{taskId}/plot.png \# ST 结果图表 (私有\!)
|
||||
|
||||
\# 3\. 机构租户 (To B)
|
||||
tenants/{tenantId}/ekb/{kbId}/documents/{year}/{id}.pdf
|
||||
tenants/{tenantId}/asl/projects/{projId}/pdfs/{id}.pdf
|
||||
tenants/{tenantId}/emr/original/{patientId}.json
|
||||
|
||||
\# 4\. 临时数据 (自动清理)
|
||||
temp/dc/sessions/{sessionId}/source.xlsx \# Tool C 原始文件 (1天删除)
|
||||
temp/dc/sessions/{sessionId}/clean\_data.json \# Tool C 中间缓存 (1天删除)
|
||||
temp/asl/imports/{date}/{uuid}.xlsx \# ASL 导入临时表 (1天删除)
|
||||
|
||||
\# 5\. 系统级数据 (Platform Assets)
|
||||
system/kb/guidelines/{year}/{fileId}.pdf \# 系统内置知识库(GCP/法规/指南)
|
||||
system/docs/manuals/{version}/{fileId}.pdf \# 用户操作手册/帮助文档
|
||||
system/samples/datasets/{fileId}.xlsx \# 系统预置演示数据(Demo Data)
|
||||
|
||||
### **2.2 静态资源目录 (ai-clinical-static / ...-dev)**
|
||||
|
||||
\# RAG 图片 & 系统资源
|
||||
images/rag/{YYYY}/{MM}/{long-random-hash-uuid}.png \# RAG 解析插图
|
||||
assets/avatars/{userId}.png \# 用户头像
|
||||
assets/system/logo.png \# 租户 Logo
|
||||
|
||||
## **3\. 后端流式上传:统一实施方案**
|
||||
|
||||
我们放弃复杂的 STS,全平台统一使用 **Node.js Stream Pipe** 模式。
|
||||
|
||||
### **3.1 基础上传流程 (ASL / RVW / SSA / ST)**
|
||||
|
||||
适用于只需存储、后续异步处理的场景。
|
||||
|
||||
**流程:**
|
||||
|
||||
1. **前端**:FormData POST 文件到后端。
|
||||
2. **后端**:
|
||||
* 使用 @fastify/multipart 获取文件流 (part.file)。
|
||||
* **限制大小**:在 Fastify 层配置 limits: { fileSize: 100MB }。
|
||||
* **流式传输**:使用 pipeline 将流直接对接 OSS SDK 的 putStream。
|
||||
* **零内存**:文件流不经过 RAM Buffer,直接走内网 I/O。
|
||||
|
||||
**代码范例:**
|
||||
|
||||
import { pipeline } from 'stream/promises';
|
||||
|
||||
// Controller
|
||||
const part \= await req.file();
|
||||
const stream \= part.file;
|
||||
const key \= \`users/${userId}/asl/.../${filename}\`;
|
||||
|
||||
// Service (common/storage)
|
||||
await ossClient.putStream(key, stream);
|
||||
// 成功后写入 DB
|
||||
|
||||
### **3.2 高级流程:流式分发 (RAG / Tool C / Tool B)**
|
||||
|
||||
适用于\*\*“既要存备份,又要立即解析”\*\*的场景。这是后端流式上传的最大优势——可以“一鱼两吃”。
|
||||
|
||||
**问题**:Stream 只能被消费一次。如果发给了 OSS,Python 微服务就拿不到了。 **解决**:使用 Node.js 的 PassThrough 创建流的分支。
|
||||
|
||||
**流程:**
|
||||
|
||||
1. **前端**:上传文件。
|
||||
2. **后端 (Node.js)**:
|
||||
* 创建两个 PassThrough 流:streamA (给 OSS), streamB (给 Python)。
|
||||
* 源流 .pipe(streamA), 源流 .pipe(streamB)。
|
||||
* **并行执行**:Promise.all(\[ 上传OSS(streamA), 调用Python(streamB) \])。
|
||||
3. **结果**:文件存下了,Markdown/清洗结果也拿到了,且只消耗了一次上传带宽。
|
||||
|
||||
**代码范例:**
|
||||
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
// 1\. 创建分支
|
||||
const uploadStream \= new PassThrough();
|
||||
const processStream \= new PassThrough();
|
||||
|
||||
// 2\. 分流
|
||||
part.file.pipe(uploadStream);
|
||||
part.file.pipe(processStream);
|
||||
|
||||
// 3\. 并行处理
|
||||
await Promise.all(\[
|
||||
// 任务A:存 OSS (备份)
|
||||
storage.uploadStream(key, uploadStream),
|
||||
|
||||
// 任务B:发给 Python 解析 (业务)
|
||||
extractionService.extract(processStream)
|
||||
\]);
|
||||
|
||||
## **4\. 权限与安全管理 (后端主导)**
|
||||
|
||||
由于前端不再直接接触 OSS,安全边界收缩至后端服务器。
|
||||
|
||||
### **4.1 RAM 角色配置 (SAE 实例身份)**
|
||||
|
||||
* **不再需要**:为每个用户签发 STS Token 的复杂逻辑。
|
||||
* **只需要**:为 SAE 实例绑定一个 RAM Role。
|
||||
* **权限策略**:
|
||||
* AliyunOSSFullAccess (简单粗暴,仅限 MVP)。
|
||||
* 或者自定义策略:仅允许对 ai-clinical-\* 开头的 Bucket 进行 PutObject, GetObject, DeleteObject。
|
||||
|
||||
### **4.2 下载/预览安全 (Presigned URL)**
|
||||
|
||||
对于私有 Bucket (ai-clinical-data) 的文件,前端如何访问?
|
||||
|
||||
* **API**:GET /api/storage/url?key=...
|
||||
* **后端**:调用 ossClient.signatureUrl(key, { expires: 3600 })。
|
||||
* **前端**:拿到带签名的长 URL,直接 window.open 或 \<img src="..."\>。
|
||||
* **安全**:签名 URL 默认 1 小时过期,且无法被枚举。
|
||||
|
||||
## **5\. 生命周期与成本控制**
|
||||
|
||||
利用 OSS 规则自动兜底,防止垃圾文件堆积。
|
||||
|
||||
| Bucket | 目录前缀 | 策略 | 目的 |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| **Data (Prod)** | temp/ | **1天后删除** | **Tool C Session、ASL 导入 Excel,统统自动清理。** |
|
||||
| **Data (Prod)** | users/\*/st/ | **90天后转归档** | ST 小工具历史记录。 |
|
||||
| **Data (Prod)** | business/dc/ | **30天后转低频** | Tool B 长期任务。 |
|
||||
| **Data (Dev)** | **整个 Bucket** | **30天后删除** | 开发环境自动复位。 |
|
||||
|
||||
## **6\. 实施 Checklist (V4.3)**
|
||||
|
||||
* ![][image1]**1\. Bucket 创建与配置**
|
||||
* **![][image1]**创建 4 个 Bucket (Prod/Dev x Data/Static)。
|
||||
* ![][image1]**Prod Data Bucket**:开启 SSE-OSS 加密。
|
||||
* ![][image1]**OSS CORS**:仅配置 GET 方法 (Allow \* Headers)。
|
||||
* ![][image1]**生命周期**:配置 temp/ 1天删除规则。
|
||||
* ![][image1]**2\. 环境变量配置 (SAE & Local)**
|
||||
* **![][image1]**OSS\_ENDPOINT: 生产环境务必填 oss-cn-beijing-internal.aliyuncs.com。
|
||||
* ![][image1]OSS\_ACCESS\_KEY\_ID: 使用 SAE 绑定的 RAM Role (最佳) 或 专用 AK。
|
||||
* ![][image1]**3\. 后端代码实现**
|
||||
* **![][image1]**完善 common/storage/OssAdapter.ts:实现 uploadStream 方法。
|
||||
* ![][image1]完善 SessionService.ts (Tool C):使用 PassThrough 实现“边存边算”。
|
||||
* ![][image1]完善 ReviewService.ts (RVW):实现报告生成的流式上传。
|
||||
* ![][image1]**4\. 移除旧代码** (清理债)
|
||||
* ![][image1]删除所有 STS Token 获取接口 (get-sts-token)。
|
||||
* ![][image1]删除前端所有 ali-oss 依赖。
|
||||
|
||||
## **7\. 逆向审计:潜在风险与应对策略 (Red Teaming)**
|
||||
|
||||
从**逆向思维**角度审视“全后端流式上传”方案,梳理出物理层与逻辑层的 7 大隐患。
|
||||
|
||||
### **7.1 物理网络风险 (Physical Layer)**
|
||||
|
||||
#### **风险 1:带宽“发卡弯”效应 (The Bandwidth Hairpin)**
|
||||
|
||||
* **原理**:流量路径为 用户 \-\> SAE (公网入) \-\> OSS (内网出)。
|
||||
* **隐患**:SAE 到 OSS 虽快,但 **用户到 SAE 的入网带宽 (Ingress)** 是受限的。如果 SAE 公网带宽仅购买了 5Mbps,所有用户上传总速度将被卡死在 600KB/s。
|
||||
* **✅ 应对策略**:
|
||||
* **SAE 计费模式**:务必选择 **"按流量计费"** (Pay-By-Traffic),将带宽峰值拉高至 **100Mbps**。这样既保证了速度,又只在有上传时扣费,成本可控。
|
||||
|
||||
#### **风险 2:浏览器连接槽位耗尽 (Connection Slot Exhaustion)**
|
||||
|
||||
* **场景**:ASL 模块批量上传 100 个文件。
|
||||
* **隐患**:浏览器对同一域名的并发连接数有限制(通常 6 个)。如果前端并发 100 个请求,后续请求会处于 Pending 状态,甚至超时失败。
|
||||
* **✅ 应对策略**:
|
||||
* **前端队列**:前端上传组件必须实现队列机制(Queueing),严格控制 concurrency: 3。
|
||||
|
||||
### **7.2 Runtime 风险 (Application Layer)**
|
||||
|
||||
#### **风险 3:Node.js 流背压 (Stream Backpressure)**
|
||||
|
||||
* **场景**:使用 PassThrough 分流时,用户网速极快 (5G),但 Python 微服务处理较慢。
|
||||
* **隐患**:Node.js 会在内存中缓存积压的数据。如果文件很大且 Python 处理很慢,可能导致 Node.js 内存飙升 (OOM)。
|
||||
* **✅ 应对策略**:
|
||||
* **流式接收**:确保 Python 微服务支持流式接收 (StreamingResponse),而不是等接收完整个 Body 再处理。
|
||||
* **熔断机制**:如果 Python 响应超时,Node.js 应主动切断该分支,但不影响主上传流(OSS)。
|
||||
|
||||
#### **风险 4:大文件内存溢出 (OOM)**
|
||||
|
||||
* **场景**:开发者误用了 await part.toBuffer()。
|
||||
* **后果**:大文件直接进堆内存,服务卡死。
|
||||
* **✅ 应对策略**:
|
||||
* **Code Review**:严禁在上传接口使用 toBuffer(),必须使用 pipe。
|
||||
|
||||
### **7.3 注意事项
|
||||
|
||||
我们统一用 Node.js 的 Stream Pipeline,水管直接接通 OSS 内网,数据不进内存。代码我已经让人整理好了,就参照 common/storage 模块写,这套逻辑全平台复用。”
|
||||
|
||||
这 3 条红线,请遵守:
|
||||
|
||||
红线 1:SAE 的环境变量 OSS_ENDPOINT 必须填内网地址 (-internal),否则流量费会让你肉疼。
|
||||
|
||||
红线 2:后端代码里严禁出现 await file.toBuffer() 这种写法,必须用 stream.pipeline (新流式)。这是 2 人团队服务器不崩的关键。
|
||||
|
||||
红线 3:ai-clinical-data Bucket 绝对不能开公有读,不管测试有多方便都不行。
|
||||
165
docs/01-平台基础层/02-存储服务/OSS账号与配置信息.md
Normal file
165
docs/01-平台基础层/02-存储服务/OSS账号与配置信息.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# OSS 账号与配置信息
|
||||
|
||||
> **创建日期:** 2026-01-22
|
||||
> **状态:** 已配置
|
||||
> **⚠️ 安全提醒:** 本文档不包含敏感密钥,AccessKey Secret 请从安全渠道获取
|
||||
|
||||
---
|
||||
|
||||
## 1. RAM 服务账号
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| **显示名称** | AI临床研究OSS服务账号 |
|
||||
| **登录名称** | `aiclinical-oss@1991407246109125.onaliyun.com` |
|
||||
| **AccessKey ID** | `LTAI5tBHkL39GjdLfcr77Y3f` |
|
||||
| **AccessKey Secret** | `********` (见 .env 文件或联系管理员) |
|
||||
| **安全手机** | 86-18611348738 |
|
||||
| **安全邮箱** | gofeng117@163.com |
|
||||
| **授权策略** | AliyunOSSFullAccess |
|
||||
|
||||
---
|
||||
|
||||
## 2. Bucket 配置清单
|
||||
|
||||
### 2.1 生产环境
|
||||
|
||||
| Bucket 名称 | 区域 | ACL | 加密 | 用途 |
|
||||
|-------------|------|-----|------|------|
|
||||
| `ai-clinical-data` | 华北2(北京) | 私有 | SSE-OSS (AES256) | 核心数据(文献、病历、报告) |
|
||||
| `ai-clinical-static` | 华北2(北京) | 公共读 | 无 | 静态资源(头像、Logo、RAG图片) |
|
||||
|
||||
### 2.2 开发环境
|
||||
|
||||
| Bucket 名称 | 区域 | ACL | 加密 | 用途 |
|
||||
|-------------|------|-----|------|------|
|
||||
| `ai-clinical-data-dev` | 华北2(北京) | 私有 | 无 | 开发测试核心数据 |
|
||||
| `ai-clinical-static-dev` | 华北2(北京) | 公共读 | 无 | 开发测试静态资源 |
|
||||
|
||||
---
|
||||
|
||||
## 3. CORS 配置(所有 Bucket 相同)
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| 来源 (AllowedOrigin) | `*` |
|
||||
| 允许 Methods | GET, HEAD |
|
||||
| 允许 Headers | `*` |
|
||||
| 暴露 Headers | `ETag, x-oss-request-id` |
|
||||
| 缓存时间 | 3600 秒 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 生命周期规则
|
||||
|
||||
### 仅 Data Bucket 配置
|
||||
|
||||
| Bucket | 规则名称 | 前缀 | 策略 |
|
||||
|--------|---------|------|------|
|
||||
| `ai-clinical-data` | auto-delete-temp | `temp/` | 1 天后删除 |
|
||||
| `ai-clinical-data-dev` | auto-delete-temp | `temp/` | 1 天后删除 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 环境变量配置
|
||||
|
||||
### 5.1 本地开发环境 (`.env.development`)
|
||||
|
||||
```bash
|
||||
# 存储类型
|
||||
STORAGE_TYPE=oss
|
||||
|
||||
# OSS 开发环境配置
|
||||
OSS_REGION=oss-cn-beijing
|
||||
OSS_BUCKET=ai-clinical-data-dev
|
||||
OSS_BUCKET_STATIC=ai-clinical-static-dev
|
||||
OSS_ACCESS_KEY_ID=LTAI5tBHkL39GjdLfcr77Y3f
|
||||
OSS_ACCESS_KEY_SECRET=<从安全渠道获取>
|
||||
OSS_INTERNAL=false
|
||||
```
|
||||
|
||||
### 5.2 SAE 生产环境
|
||||
|
||||
```bash
|
||||
# 存储类型
|
||||
STORAGE_TYPE=oss
|
||||
|
||||
# OSS 生产环境配置
|
||||
OSS_REGION=oss-cn-beijing
|
||||
OSS_BUCKET=ai-clinical-data
|
||||
OSS_BUCKET_STATIC=ai-clinical-static
|
||||
OSS_ACCESS_KEY_ID=LTAI5tBHkL39GjdLfcr77Y3f
|
||||
OSS_ACCESS_KEY_SECRET=<从安全渠道获取>
|
||||
OSS_INTERNAL=true # 🔴 生产必须用内网
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Endpoint 地址
|
||||
|
||||
| 环境 | Endpoint | 说明 |
|
||||
|------|----------|------|
|
||||
| 公网(本地开发) | `oss-cn-beijing.aliyuncs.com` | 本地开发使用 |
|
||||
| 内网(SAE 生产) | `oss-cn-beijing-internal.aliyuncs.com` | 生产环境必须使用,免流量费 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 成本预警设置
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| 预算名称 | OSS成本监控 |
|
||||
| 月预算 | 100 元 |
|
||||
| 告警阈值 | 80%, 100% |
|
||||
| 通知方式 | 短信 + 邮件 |
|
||||
| 通知手机 | 18611348738 |
|
||||
| 通知邮箱 | gofeng117@163.com |
|
||||
|
||||
---
|
||||
|
||||
## 8. 安全注意事项
|
||||
|
||||
### 🔴 绝对不要做的事
|
||||
|
||||
1. **不要**将 AccessKey Secret 提交到 Git 仓库
|
||||
2. **不要**在前端代码中使用 AccessKey
|
||||
3. **不要**将 `ai-clinical-data` Bucket 设为公共读
|
||||
4. **不要**在生产环境使用公网 Endpoint
|
||||
|
||||
### ✅ 应该做的事
|
||||
|
||||
1. AccessKey Secret 存储在 `.env` 文件(已加入 .gitignore)
|
||||
2. 定期轮换 AccessKey(建议每 90 天)
|
||||
3. 监控成本告警邮件
|
||||
4. 生产环境使用内网 Endpoint
|
||||
|
||||
---
|
||||
|
||||
## 9. 快速参考
|
||||
|
||||
### 开发环境快速测试
|
||||
|
||||
```bash
|
||||
# 1. 配置环境变量
|
||||
export OSS_REGION=oss-cn-beijing
|
||||
export OSS_BUCKET=ai-clinical-data-dev
|
||||
export OSS_ACCESS_KEY_ID=LTAI5tBHkL39GjdLfcr77Y3f
|
||||
export OSS_ACCESS_KEY_SECRET=<你的密钥>
|
||||
export OSS_INTERNAL=false
|
||||
|
||||
# 2. 启动后端
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 阿里云控制台链接
|
||||
|
||||
- [OSS 控制台](https://oss.console.aliyun.com/)
|
||||
- [RAM 控制台](https://ram.console.aliyun.com/)
|
||||
- [费用中心](https://usercenter2.aliyun.com/)
|
||||
|
||||
---
|
||||
|
||||
*文档结束。如有问题请联系:gofeng117@163.com*
|
||||
|
||||
|
||||
192
docs/01-平台基础层/02-存储服务/OSS集成开发记录-2026-01-22.md
Normal file
192
docs/01-平台基础层/02-存储服务/OSS集成开发记录-2026-01-22.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# OSS 集成开发记录
|
||||
|
||||
> 日期:2026-01-22
|
||||
> 版本:v1.0
|
||||
> 状态:MVP 阶段完成
|
||||
|
||||
---
|
||||
|
||||
## 📋 开发目标
|
||||
|
||||
将阿里云 OSS 集成到平台中,替代本地文件存储,为 PKB(个人知识库)等业务模块提供可靠的云端文件持久化能力。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 完成的工作
|
||||
|
||||
### 1. 基础设施层
|
||||
|
||||
#### 1.1 OSS Bucket 创建
|
||||
| Bucket 名称 | 用途 | 权限 | 加密 |
|
||||
|-------------|------|------|------|
|
||||
| `ai-clinical-data` | 生产环境数据存储 | 私有 | SSE-OSS |
|
||||
| `ai-clinical-data-dev` | 开发环境数据存储 | 私有 | 无 |
|
||||
| `ai-clinical-static` | 生产环境静态资源 | 公共读 | 无 |
|
||||
| `ai-clinical-static-dev` | 开发环境静态资源 | 公共读 | 无 |
|
||||
|
||||
#### 1.2 RAM 账号配置
|
||||
- 创建专用 RAM 用户:`aiclinical-oss`
|
||||
- 配置 AccessKey ID/Secret
|
||||
- 授权 OSS 操作权限
|
||||
|
||||
### 2. 后端存储适配器
|
||||
|
||||
#### 2.1 架构设计
|
||||
```
|
||||
StorageAdapter (接口)
|
||||
├── OSSAdapter (阿里云 OSS 实现)
|
||||
└── LocalAdapter (本地文件系统实现)
|
||||
|
||||
StorageFactory (工厂类)
|
||||
└── getInstance() 根据 STORAGE_TYPE 返回对应适配器
|
||||
```
|
||||
|
||||
#### 2.2 核心文件
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/common/storage/StorageAdapter.ts` | 存储适配器接口定义 |
|
||||
| `src/common/storage/OSSAdapter.ts` | OSS 实现(上传、下载、删除、签名URL) |
|
||||
| `src/common/storage/LocalAdapter.ts` | 本地存储实现 |
|
||||
| `src/common/storage/StorageFactory.ts` | 工厂类,根据环境变量创建实例 |
|
||||
| `src/common/storage/index.ts` | 统一导出 |
|
||||
|
||||
#### 2.3 关键功能
|
||||
- **上传文件**:`storage.upload(key, buffer)`
|
||||
- **下载文件**:`storage.download(key)`
|
||||
- **删除文件**:`storage.delete(key)`
|
||||
- **获取签名URL**:`storage.getSignedUrl(key, expires, originalFilename)`
|
||||
- **检查文件存在**:`storage.exists(key)`
|
||||
|
||||
#### 2.4 签名URL与原始文件名
|
||||
通过 `Content-Disposition` 响应头,让浏览器下载时恢复原始文件名:
|
||||
|
||||
```typescript
|
||||
getSignedUrl(key: string, expires: number = 900, originalFilename?: string): string {
|
||||
const options: OSS.SignatureUrlOptions = { expires };
|
||||
if (originalFilename) {
|
||||
options.response = {
|
||||
'content-disposition': `attachment; filename="${encodeURIComponent(originalFilename)}"`
|
||||
};
|
||||
}
|
||||
return this.client.signatureUrl(key, options);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. PKB 模块集成
|
||||
|
||||
#### 3.1 目录结构规范
|
||||
```
|
||||
tenants/{tenantId}/users/{userId}/pkb/{kbId}/{uuid}.{ext}
|
||||
```
|
||||
|
||||
示例:
|
||||
```
|
||||
tenants/yizhengxun/users/user-001/pkb/kb-001/9f206cc1c1ac4478.pdf
|
||||
```
|
||||
|
||||
#### 3.2 代码修改
|
||||
|
||||
**documentController.ts**:
|
||||
- 新增 `generatePkbStorageKey()` 函数生成存储路径
|
||||
- 上传流程:先上传到 OSS,再调用 documentService
|
||||
|
||||
**documentService.ts**:
|
||||
- `uploadDocument()` 接收 `storageKey` 参数并存储
|
||||
- `deleteDocument()` 删除 OSS 文件
|
||||
|
||||
#### 3.3 数据库字段重命名
|
||||
| 原字段名 | 新字段名 | 说明 |
|
||||
|----------|----------|------|
|
||||
| `difyDocumentId` | `storageKey` | 存储 OSS 路径(数据库列名保持 `dify_document_id` 以避免迁移) |
|
||||
|
||||
### 4. 环境变量配置
|
||||
|
||||
```bash
|
||||
# 存储类型:oss 或 local
|
||||
STORAGE_TYPE=oss
|
||||
|
||||
# OSS 配置
|
||||
OSS_REGION=oss-cn-beijing
|
||||
OSS_BUCKET=ai-clinical-data-dev
|
||||
OSS_BUCKET_STATIC=ai-clinical-static-dev
|
||||
OSS_ACCESS_KEY_ID=LTAI5tBHkL39GjdLfcr77Y3f
|
||||
OSS_ACCESS_KEY_SECRET=********
|
||||
OSS_INTERNAL=false # 本地开发用公网,生产用内网
|
||||
```
|
||||
|
||||
### 5. 文档产出
|
||||
|
||||
| 文档 | 路径 |
|
||||
|------|------|
|
||||
| MVP 实施方案 | `docs/01-平台基础层/02-存储服务/OSS存储实施方案-MVP版.md` |
|
||||
| 开发规范 | `docs/04-开发规范/11-OSS存储开发规范.md` |
|
||||
| 账号配置信息 | `docs/01-平台基础层/02-存储服务/OSS账号与配置信息.md` |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试结果
|
||||
|
||||
### OSS 适配器测试
|
||||
```
|
||||
✅ 上传成功
|
||||
✅ 文件存在检查
|
||||
✅ 下载成功(内容匹配)
|
||||
✅ 签名URL生成(含原始文件名)
|
||||
✅ 删除成功
|
||||
```
|
||||
|
||||
### PKB 文档上传测试
|
||||
```
|
||||
✅ 文件上传到 OSS
|
||||
✅ 数据库记录 storageKey
|
||||
⚠️ 删除文档时遇到 pg-boss 队列冲突(与 OSS 无关)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 待解决问题
|
||||
|
||||
| 问题 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| pg-boss 队列重复初始化 | 高 | 导致服务启动报错 |
|
||||
| 删除文档功能验证 | 中 | 需解决 pg-boss 后继续测试 |
|
||||
| 前端文件预览/下载 | 中 | 需实现签名URL获取接口 |
|
||||
| Tool C / RVW / ASL 集成 | 低 | 按实施方案逐步推进 |
|
||||
|
||||
---
|
||||
|
||||
## 📊 技术决策记录
|
||||
|
||||
### 决策 1:文件命名使用 UUID
|
||||
- **原因**:防止文件名冲突、避免特殊字符问题、增加安全性
|
||||
- **方案**:上传时生成 UUID 作为 OSS Key,原始文件名存数据库
|
||||
|
||||
### 决策 2:保留 local 存储模式
|
||||
- **原因**:支持医疗机构私有化部署(数据不出内网)
|
||||
- **方案**:通过 `STORAGE_TYPE` 环境变量切换
|
||||
|
||||
### 决策 3:复用 difyDocumentId 字段
|
||||
- **原因**:Dify 已移除,该字段可复用存储 OSS 路径
|
||||
- **方案**:Prisma 字段重命名为 `storageKey`,`@map` 保持原列名
|
||||
|
||||
### 决策 4:文件大小限制 30MB
|
||||
- **原因**:平衡用户体验和服务器内存安全
|
||||
- **方案**:30MB 以下用 `toBuffer()`,超大文件考虑流式处理
|
||||
|
||||
---
|
||||
|
||||
## 📅 后续计划
|
||||
|
||||
1. **Phase 2.1**:完成 PKB 删除功能验证
|
||||
2. **Phase 2.2**:实现前端文件预览/下载(签名URL)
|
||||
3. **Phase 3**:集成 Tool C、RVW、ASL 模块
|
||||
4. **Phase 4**:生产环境部署与内网 Endpoint 配置
|
||||
|
||||
---
|
||||
|
||||
## 👥 参与人员
|
||||
|
||||
- 开发:AI 助手 + 用户
|
||||
- 审核:用户
|
||||
|
||||
|
||||
Reference in New Issue
Block a user