# OSS 存储开发规范 > **版本:** v1.0 > **创建日期:** 2026-01-22 > **更新日期:** 2026-01-22 > **适用范围:** 全项目后端开发 > **优先级:** ⭐⭐⭐⭐⭐ P0 必须遵守 --- ## 📋 目录 1. [核心原则](#1-核心原则) 2. [目录结构规范](#2-目录结构规范) 3. [Key 生成规则](#3-key-生成规则) 4. [文件命名规范](#4-文件命名规范) 5. [API 使用规范](#5-api-使用规范) 6. [安全红线](#6-安全红线) 7. [最佳实践](#7-最佳实践) 8. [常见问题](#8-常见问题) --- ## 1. 核心原则 ### 1.1 统一管控 ``` ┌─────────────────────────────────────────────────────────────┐ │ OSS 存储核心原则 │ ├─────────────────────────────────────────────────────────────┤ │ ✅ 后端统一管控:前端只提交 FormData,不接触存储层 │ │ ✅ 适配器模式:支持本地/OSS无缝切换,业务代码无感知 │ │ ✅ UUID 命名:文件存储使用 UUID,原始文件名存数据库 │ │ ✅ 签名 URL:私有文件通过签名 URL 访问,支持过期时间 │ └─────────────────────────────────────────────────────────────┘ ``` ### 1.2 部署模式 系统支持两种部署模式,通过 `STORAGE_TYPE` 环境变量切换: | 部署模式 | 配置值 | 适用场景 | 数据位置 | |---------|--------|---------|---------| | **SaaS 云端** | `oss` | 壹证循科技 SaaS 服务 | 阿里云 OSS | | **私有化部署** | `local` | 医疗机构本地部署 | 本地服务器磁盘 | ``` ┌─────────────────────────────────────────────────────────────┐ │ 两种部署模式 │ ├──────────────────────────┬──────────────────────────────────┤ │ SaaS 云端模式 │ 私有化本地部署 │ │ STORAGE_TYPE=oss │ STORAGE_TYPE=local │ ├──────────────────────────┼──────────────────────────────────┤ │ ☁️ 阿里云 OSS │ 🏥 医疗机构本地服务器 │ │ • 高可用 │ • 数据不出内网 │ │ • 自动扩容 │ • 满足医疗数据合规 │ │ • CDN 加速 │ • 100% 数据自主可控 │ └──────────────────────────┴──────────────────────────────────┘ ``` > 💡 **设计理念**:业务代码使用统一的 `storage` 接口,无需关心底层是 OSS 还是本地文件系统。 ### 1.3 存储资源分类 #### SaaS 云端模式(阿里云 OSS) | 环境 | Bucket | ACL | 用途 | |------|--------|-----|------| | 生产 | `ai-clinical-data` | 私有 | 核心数据(文献、病历、报告) | | 生产 | `ai-clinical-static` | 公共读 | 静态资源(头像、Logo) | | 开发 | `ai-clinical-data-dev` | 私有 | 开发测试数据 | | 开发 | `ai-clinical-static-dev` | 公共读 | 开发测试静态资源 | #### 私有化本地部署 | 目录 | 访问方式 | 用途 | |------|---------|------| | `/data/uploads/` | Nginx 代理 | 所有文件(私有+静态) | > 💡 私有化部署时,通过 Nginx 配置访问控制,实现类似 OSS 的私有/公共读策略。 --- ## 2. 目录结构规范 ### 2.1 完整目录结构 ``` ai-clinical-data[-dev]/ ├── tenants/ # 租户数据根目录 │ └── {tenantId}/ # 租户标识(如:yizhengxun) │ ├── users/ # 用户私有数据 │ │ └── {userId}/ # 用户标识 │ │ ├── pkb/ # 个人知识库文献 │ │ │ └── {kbId}/ # 知识库ID(便于按库管理) │ │ │ └── {uuid}.pdf │ │ ├── asl/ # 智能文献 PDF │ │ │ └── {projectId}/ # 项目ID │ │ │ └── {uuid}.pdf │ │ ├── rvw/ # 审稿稿件和报告 │ │ │ └── {taskId}/ │ │ │ ├── source.docx │ │ │ └── report.docx │ │ └── ssa/ # 统计分析数据 │ │ └── {uuid}.xlsx │ │ │ └── shared/ # 租户共享数据 │ ├── ekb/ # 企业知识库(全员共享) │ │ └── {uuid}.pdf │ ├── emr/ # 病历数据 │ │ └── {uuid}.json │ └── templates/ # 机构模板 │ └── {uuid}.docx │ ├── temp/ # 临时文件(1天自动删除) │ └── {YYYYMMDD}/ # 按日期分组 │ └── {uuid}.xlsx # Tool C 上传、ASL 导入等 │ └── system/ # 系统级资源 ├── knowledge-bases/ # 系统知识库(Prompt 等) │ └── {kbId}/ │ └── {docId}.pdf ├── iit-knowledge-bases/ # IIT 项目知识库(按项目隔离) │ └── {kbId}/ │ └── {docId}.pdf ├── templates/ # 预置模板 │ └── gcp_guide.pdf └── samples/ # 演示数据 └── demo_data.xlsx ``` ### 2.2 目录类型说明 | 目录类型 | 路径格式 | 生命周期 | 访问权限 | |----------|---------|---------|---------| | 用户私有 | `tenants/{tenantId}/users/{userId}/{module}/` | 永久 | 仅本人 | | 租户共享 | `tenants/{tenantId}/shared/{module}/` | 永久 | 租户全员 | | 临时文件 | `temp/{YYYYMMDD}/` | 1天 | 系统内部 | | 系统文件 | `system/{category}/` | 永久 | 所有用户 | ### 2.3 模块代码对照表 | 模块 | 代码 | 典型文件类型 | 存储位置 | |------|------|-------------|---------| | PKB 个人知识库 | `pkb` | PDF、Word | 用户私有 | | ASL 智能文献 | `asl` | PDF | 用户私有 | | RVW 审稿系统 | `rvw` | Word、PDF | 用户私有 | | SSA 统计分析 | `ssa` | Excel | 用户私有 | | EKB 企业知识库 | `ekb` | PDF | 租户共享 | | EMR 病历数据 | `emr` | JSON | 租户共享 | | IIT 项目知识库 | `iit-kb` | PDF、Word | 系统文件 (`system/iit-knowledge-bases/`) | | Tool C 数据清洗 | - | Excel | 临时文件 | --- ## 3. Key 生成规则 ### 3.1 统一生成函数 ```typescript import { randomUUID } from 'crypto'; import path from 'path'; /** * 生成 OSS 存储 Key * * @param tenantId - 租户ID(如:yizhengxun) * @param userId - 用户ID(null 表示租户共享) * @param module - 模块代码(如:pkb、asl、rvw) * @param filename - 原始文件名(用于获取扩展名) * @returns OSS 存储路径 */ function generateStorageKey( tenantId: string, userId: string | null, module: string, filename: string ): string { // 生成 16 位 UUID(简洁版) const uuid = randomUUID().replace(/-/g, '').substring(0, 16); const ext = path.extname(filename).toLowerCase(); if (userId) { // 用户私有数据 return `tenants/${tenantId}/users/${userId}/${module}/${uuid}${ext}`; } else { // 租户共享数据 return `tenants/${tenantId}/shared/${module}/${uuid}${ext}`; } } /** * 生成临时文件 Key * * @param filename - 原始文件名 * @returns 临时文件路径(1天后自动删除) */ function generateTempKey(filename: string): string { const uuid = randomUUID().replace(/-/g, '').substring(0, 16); const ext = path.extname(filename).toLowerCase(); const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); return `temp/${date}/${uuid}${ext}`; } ``` ### 3.2 使用示例 ```typescript // 示例 1:PKB 用户上传文献 const key1 = generateStorageKey('yizhengxun', 'user-001', 'pkb', '临床研究.pdf'); // → tenants/yizhengxun/users/user-001/pkb/9f206cc1c1ac4478.pdf // 示例 2:EKB 租户共享知识库 const key2 = generateStorageKey('yizhengxun', null, 'ekb', 'GCP指南.pdf'); // → tenants/yizhengxun/shared/ekb/a1b2c3d4e5f6g7h8.pdf // 示例 3:Tool C 临时文件 const key3 = generateTempKey('原始数据.xlsx'); // → temp/20260122/b2c3d4e5f6g7h8i9.xlsx ``` --- ## 4. 文件命名规范 ### 4.1 为什么使用 UUID 命名? | 原因 | 说明 | |------|------| | **防止冲突** | 多个用户可能上传同名文件 `paper.pdf` | | **避免编码问题** | 中文、空格等特殊字符会导致 URL 编码问题 | | **安全性** | 不暴露原始文件名,防止信息泄露 | | **一致性** | 统一格式便于管理和检索 | ### 4.2 原始文件名处理 ``` ┌─────────────────────────────────────────────────────────────┐ │ 上传时 │ │ ────────────────────────────────────────────────────────── │ │ 原始文件名: "Ihl 2011.pdf" │ │ ↓ │ │ 存入数据库: filename = "Ihl 2011.pdf" │ │ ↓ │ │ OSS Key: tenants/xxx/pkb/9f206cc1c1ac4478.pdf │ │ │ │ 下载时 │ │ ────────────────────────────────────────────────────────── │ │ 从数据库读取: filename = "Ihl 2011.pdf" │ │ ↓ │ │ 生成带文件名的签名 URL(Content-Disposition) │ │ ↓ │ │ 浏览器下载保存为: "Ihl 2011.pdf" ✅ │ └─────────────────────────────────────────────────────────────┘ ``` ### 4.3 数据库字段设计 ```prisma model Document { id String @id @default(uuid()) filename String // 原始文件名(用于下载时恢复) storageKey String // OSS 存储路径 fileUrl String? // 签名 URL(可缓存) fileSizeBytes Int // ... } ``` --- ## 5. API 使用规范 ### 5.1 存储服务导入 ```typescript // ✅ 推荐:使用全局单例 import { storage, staticStorage } from '@/common/storage'; // storage: 私有数据 Bucket(ai-clinical-data) // staticStorage: 静态资源 Bucket(ai-clinical-static) ``` ### 5.2 上传文件 ```typescript // 上传到私有 Bucket const storageKey = generateStorageKey(tenantId, userId, 'pkb', filename); const url = await storage.upload(storageKey, buffer); // 上传到静态 Bucket(公开访问) const avatarKey = `avatars/${userId}/${uuid}.jpg`; const avatarUrl = await staticStorage.upload(avatarKey, buffer); ``` ### 5.3 获取签名 URL ```typescript // 方式 1:不指定文件名(下载为 UUID 文件名) const url1 = storage.getUrl(storageKey); // 方式 2:指定原始文件名(下载时恢复原始文件名)✅ 推荐 const ossAdapter = storage as OSSAdapter; const url2 = ossAdapter.getSignedUrl(storageKey, 3600, '原始文件名.pdf'); ``` ### 5.4 下载文件 ```typescript // 下载文件内容 const buffer = await storage.download(storageKey); ``` ### 5.5 删除文件 ```typescript // 删除单个文件 await storage.delete(storageKey); // 批量删除(仅 OSSAdapter 支持) const ossAdapter = storage as OSSAdapter; await ossAdapter.deleteMany([key1, key2, key3]); ``` ### 5.6 检查文件存在 ```typescript const exists = await storage.exists(storageKey); if (!exists) { throw new Error('文件不存在'); } ``` --- ## 6. 安全红线 ### 🔴 红线 1:生产环境必须使用内网 ```bash # ✅ 正确:SAE 生产环境 OSS_INTERNAL=true # ❌ 错误:生产环境用公网 OSS_INTERNAL=false # 流量费用暴增! ``` **违反后果**:流量费用暴增,大文件上传卡死 ### 🔴 红线 2:私有 Bucket 绝不公开 ``` ai-clinical-data Bucket 必须设置为 Private 绝对不能为了测试方便而开放公共读 ``` **违反后果**:医疗数据泄露,合规风险 ### 🔴 红线 3:文件大小限制 | 文件大小 | 处理方式 | 说明 | |----------|---------|------| | < 30MB | `toBuffer()` ✅ | 简单直接 | | 30-50MB | 两者皆可 | 视场景选择 | | > 50MB | `stream.pipeline` 🚨 | 必须用流式 | ```typescript // Fastify 配置硬限制 fastify.register(multipart, { limits: { fileSize: 30 * 1024 * 1024 // 30MB 硬限制 } }); ``` ### 🔴 红线 4:前端禁止直接访问 OSS ```typescript // ❌ 错误:前端直接使用 AccessKey const client = new OSS({ accessKeyId: 'xxx', // 绝对不能暴露! accessKeySecret: 'xxx' // 绝对不能暴露! }); // ✅ 正确:通过后端 API 获取签名 URL const response = await api.get('/storage/signed-url', { params: { key } }); const signedUrl = response.data.url; ``` --- ## 7. 最佳实践 ### 7.1 上传流程(完整示例) ```typescript // modules/pkb/controllers/documentController.ts export async function uploadDocument(request, reply) { // 1. 获取文件和用户信息 const data = await request.file(); const buffer = await data.toBuffer(); const userId = getUserId(request); const tenantId = request.user.tenantId; // 2. 生成存储 Key const storageKey = generateStorageKey(tenantId, userId, 'pkb', data.filename); // 3. 上传到 OSS await storage.upload(storageKey, buffer); // 4. 保存到数据库(包含原始文件名) const document = await prisma.document.create({ data: { userId, kbId, filename: data.filename, // 原始文件名 storageKey: storageKey, // OSS 路径 fileSizeBytes: buffer.length, fileType: data.mimetype, } }); return reply.status(201).send({ success: true, data: document }); } ``` ### 7.2 下载流程(带原始文件名) ```typescript // modules/common/controllers/storageController.ts export async function getSignedUrl(request, reply) { const { documentId } = request.params; const userId = getUserId(request); // 1. 查询文档信息 const document = await prisma.document.findUnique({ where: { id: documentId } }); if (!document) { return reply.status(404).send({ error: '文档不存在' }); } // 2. 权限校验 if (document.userId !== userId) { return reply.status(403).send({ error: '无权访问' }); } // 3. 生成带原始文件名的签名 URL const ossAdapter = storage as OSSAdapter; const signedUrl = ossAdapter.getSignedUrl( document.storageKey, 3600, // 1小时过期 document.filename // 原始文件名 ); return reply.send({ success: true, data: { url: signedUrl, filename: document.filename, expiresIn: 3600 } }); } ``` ### 7.3 前端文件预览 ```typescript // components/FileViewer.tsx interface FileViewerProps { storageKey: string; filename: string; } export function FileViewer({ storageKey, filename }: FileViewerProps) { const [signedUrl, setSignedUrl] = useState(null); const ext = filename.split('.').pop()?.toLowerCase(); useEffect(() => { fetchSignedUrl(storageKey).then(setSignedUrl); }, [storageKey]); if (!signedUrl) return ; // PDF 内嵌预览 if (ext === 'pdf') { return (