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

@@ -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`
- [ ] 阿里云创建 Bucket4个
- [ ] 阿里云配置 CORS仅 GET
- [ ] 阿里云配置生命周期规则(`temp/` 1天删除
- [ ] 阿里云设置成本预警100元/月)
- [ ] SAE 配置环境变量
---
## 9. 相关文档
- [OSS存储架构规划与最佳实践 V4.2](./OSS存储架构规划与最佳实践V4.2.md) - 详细架构设计
- [OSS存储架构规划与最佳实践 V5](./OSS存储架构规划与最佳实践%20V5.md) - 极简版设计
- [云原生开发规范](../../04-开发规范/08-云原生开发规范.md) - 存储抽象层说明
---
*文档结束。实施前请确认所有红线规则已理解。*

View 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 存储) 时。
* **行动**:此时再去研究“生命周期管理”和“归档存储”。在此之前,专注于业务代码。

View 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 只能被消费一次。如果发给了 OSSPython 微服务就拿不到了。 **解决**:使用 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)**
#### **风险 3Node.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 条红线,请遵守:
红线 1SAE 的环境变量 OSS_ENDPOINT 必须填内网地址 (-internal),否则流量费会让你肉疼。
红线 2后端代码里严禁出现 await file.toBuffer() 这种写法,必须用 stream.pipeline (新流式)。这是 2 人团队服务器不崩的关键。
红线 3ai-clinical-data Bucket 绝对不能开公有读,不管测试有多方便都不行。

View 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*

View 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 助手 + 用户
- 审核:用户