# 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 download(key: string): Promise delete(key: string): Promise getUrl(key: string): string exists(key: string): Promise // 新增方法 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 { const result = await this.client.put(key, buffer); return result.url; } async download(key: string): Promise { const result = await this.client.get(key); return result.content as Buffer; } async delete(key: string): Promise { 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 { 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** | 内嵌预览 | `