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,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 绝对不能开公有读,不管测试有多方便都不行。