20 KiB
20 KiB
OSS 存储实施方案 - MVP版
文档版本: v1.0
创建日期: 2026-01-22
状态: 待实施
适用团队: 2人开发团队
1. 背景与目标
1.1 当前问题
| 问题 | 影响 | 严重程度 |
|---|---|---|
OSSAdapter 未实现 |
无法部署到阿里云 | 🔴 高 |
| PKB 文件未持久化 | 原始文件丢失,无法重新处理 | 🔴 高 |
ali-oss 依赖未安装 |
OSS 代码无法运行 | 🔴 高 |
| 缺少签名 URL 方法 | 私有文件无法安全访问 | 🟡 中 |
1.2 实施目标
- 完成 OSSAdapter 实现 - 支持本地/云端无缝切换
- PKB 文件持久化 - 上传时存储原始文件到 OSS
- 开发环境可测试 - 本地开发使用 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 小时)
覆盖 PKB、Tool C、RVW、ASL 四个核心模块。
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 前端消费策略
| 文件类型 | 消费方式 | 实现方法 |
|---|---|---|
| 内嵌预览 | <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 控制台配置
- 创建 Bucket:
ai-clinical-data,区域选北京,ACL 私有 - CORS 配置:
- 来源:
* - 允许 Methods:
GET - 允许 Headers:
*
- 来源:
- 生命周期规则:
- 前缀:
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 阿里云预算告警设置
- 登录阿里云控制台 → 费用中心 → 预算管理
- 创建预算:OSS 服务,月预算 100 元
- 告警阈值:80%、100%
- 通知方式:短信 + 邮件
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.tsKey 格式符合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存储架构规划与最佳实践 V5 - 极简版设计
- 云原生开发规范 - 存储抽象层说明
文档结束。实施前请确认所有红线规则已理解。