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

@@ -1,8 +1,8 @@
# Git 提交规范
> **版本:** v1.2
> **版本:** v1.3
> **创建日期:** 2025-11-16
> **更新日期:** 2026-01-16
> **更新日期:** 2026-01-22
> **适用范围:** 全项目(前端 + 后端 + 文档)
> **优先级:** ⭐⭐⭐⭐⭐ P0 必须遵守
@@ -1280,8 +1280,20 @@ git rebase -i HEAD~3 # 修改最近 3 次提交
**提交内容:**
- ✅ 只提交相关的更改
- ✅ 使用 `.gitignore` 排除无关文件
- ❌ 不要提交生成的文件(`node_modules/`, `dist/`, `.env`
- ❌ 不要提交敏感信息(密码、密钥
- **提交 `backend/.env` 环境变量文件**(私有仓库,防止配置丢失
- ❌ 不要提交生成的文件(`node_modules/`, `dist/`
> ⚠️ **关于 `.env` 文件的特殊说明2026-01-22 更新)**
>
> **背景**2026-01-22 发生环境变量文件被意外覆盖事件,导致大量配置丢失。
>
> **决策**鉴于本项目为私有仓库且团队规模小2人为防止配置丢失
> 决定将 `backend/.env` 文件纳入 Git 版本管理。
>
> **注意事项**
> - 🔴 如果仓库将来变为公开,必须立即从 Git 历史中删除 `.env`
> - 🔴 定期轮换 API 密钥(建议每 90 天)
> - 🔴 不要将 `.env` 文件分享给非团队成员
**分支管理:**
- ✅ 从最新的 `develop` 创建功能分支

View File

@@ -330,3 +330,6 @@ npx tsx check_iit_asl_data.ts

View File

@@ -198,3 +198,6 @@ interface DecodedToken {

View File

@@ -0,0 +1,654 @@
# 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/ # 系统级资源
├── 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 | 租户共享 |
| Tool C 数据清洗 | - | Excel | 临时文件 |
---
## 3. Key 生成规则
### 3.1 统一生成函数
```typescript
import { randomUUID } from 'crypto';
import path from 'path';
/**
* 生成 OSS 存储 Key
*
* @param tenantId - 租户IDyizhengxun
* @param userId - 用户IDnull 表示租户共享)
* @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
// 示例 1PKB 用户上传文献
const key1 = generateStorageKey('yizhengxun', 'user-001', 'pkb', '临床研究.pdf');
// → tenants/yizhengxun/users/user-001/pkb/9f206cc1c1ac4478.pdf
// 示例 2EKB 租户共享知识库
const key2 = generateStorageKey('yizhengxun', null, 'ekb', 'GCP指南.pdf');
// → tenants/yizhengxun/shared/ekb/a1b2c3d4e5f6g7h8.pdf
// 示例 3Tool 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" │
│ ↓ │
│ 生成带文件名的签名 URLContent-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: 私有数据 Bucketai-clinical-data
// staticStorage: 静态资源 Bucketai-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<string | null>(null);
const ext = filename.split('.').pop()?.toLowerCase();
useEffect(() => {
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'].includes(ext || '')) {
return (
<Button onClick={() => window.open(signedUrl, '_blank')}>
{filename}
</Button>
);
}
// 图片显示
if (['png', 'jpg', 'jpeg'].includes(ext || '')) {
return <img src={signedUrl} alt={filename} />;
}
return <a href={signedUrl} download={filename}></a>;
}
```
### 7.4 签名 URL 自动刷新
```typescript
// hooks/useSignedUrl.ts
function useSignedUrl(storageKey: string, expiresIn: number = 3600) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let timer: NodeJS.Timeout;
const refresh = async () => {
const newUrl = await fetchSignedUrl(storageKey);
setUrl(newUrl);
// 在过期前 1 分钟自动刷新
timer = setTimeout(refresh, (expiresIn - 60) * 1000);
};
refresh();
return () => clearTimeout(timer);
}, [storageKey, expiresIn]);
return url;
}
```
---
## 8. 常见问题
### Q1: 为什么下载的文件名是 UUID
**原因**OSS 存储使用 UUID 命名,默认下载会使用存储路径中的文件名。
**解决**:使用 `getSignedUrl` 时传入原始文件名:
```typescript
ossAdapter.getSignedUrl(storageKey, 3600, '原始文件名.pdf');
```
### Q2: 签名 URL 过期了怎么办?
**原因**:签名 URL 有过期时间(默认 1 小时)。
**解决**
1. 前端实现自动刷新(见 7.4
2. 长时间预览场景适当延长过期时间
3. 用户刷新页面时重新获取
### Q3: 环境配置指南
#### 场景 1SaaS 云端部署(壹证循科技)
**本地开发环境**(连接开发 Bucket
```bash
# backend/.env
STORAGE_TYPE=oss
OSS_REGION=oss-cn-beijing
OSS_BUCKET=ai-clinical-data-dev # 开发 Bucket
OSS_BUCKET_STATIC=ai-clinical-static-dev # 开发静态 Bucket
OSS_ACCESS_KEY_ID=LTAI5tBHkL39GjdLfcr77Y3f
OSS_ACCESS_KEY_SECRET=<从安全渠道获取>
OSS_INTERNAL=false # 本地用公网
```
**SAE 生产环境**
```bash
# SAE 环境变量配置
STORAGE_TYPE=oss
OSS_REGION=oss-cn-beijing
OSS_BUCKET=ai-clinical-data # 生产 Bucket
OSS_BUCKET_STATIC=ai-clinical-static # 生产静态 Bucket
OSS_ACCESS_KEY_ID=LTAI5tBHkL39GjdLfcr77Y3f
OSS_ACCESS_KEY_SECRET=<从安全渠道获取>
OSS_INTERNAL=true # 🔴 生产必须用内网!
```
#### 场景 2私有化本地部署医疗机构
```bash
# 医疗机构服务器 .env
STORAGE_TYPE=local
LOCAL_STORAGE_DIR=/data/ai-clinical/uploads # 挂载的持久化存储目录
LOCAL_STORAGE_URL=https://internal.hospital.com/uploads # 内网访问 URL
```
**私有化部署要点:**
- 🏥 `LOCAL_STORAGE_DIR` 应挂载到持久化存储NAS/SAN
- 🔒 数据完全在医疗机构内网,满足医疗数据合规要求
- 💾 建议配合定期备份策略
#### 配置对比
| 配置项 | SaaS 开发 | SaaS 生产 | 私有化部署 |
|--------|---------|---------|-----------|
| `STORAGE_TYPE` | `oss` | `oss` | `local` |
| `OSS_BUCKET` | `*-dev` | 生产 Bucket | - |
| `OSS_INTERNAL` | `false` | `true` | - |
| `LOCAL_STORAGE_DIR` | - | - | `/data/uploads` |
| 数据位置 | 阿里云 | 阿里云 | 本地服务器 |
### Q4: 如何清理测试数据?
```bash
# 临时文件OSS 生命周期规则自动清理temp/ 目录 1 天后删除)
# 测试文件:手动删除
await storage.delete('tenants/xxx/users/xxx/pkb/xxx.pdf');
```
### Q5: 文件上传失败如何处理?
```typescript
try {
await storage.upload(storageKey, buffer);
} catch (error) {
// 1. 记录日志
logger.error('文件上传失败', { storageKey, error });
// 2. 返回友好错误
return reply.status(500).send({
success: false,
message: '文件上传失败,请重试'
});
}
```
---
## 📚 相关文档
- [OSS存储实施方案-MVP版](../01-平台基础层/02-存储服务/OSS存储实施方案-MVP版.md)
- [OSS账号与配置信息](../01-平台基础层/02-存储服务/OSS账号与配置信息.md)
- [云原生开发规范](./08-云原生开发规范.md)
---
*文档结束。如有疑问请联系技术负责人。*