QC System Deep Fix: - HardRuleEngine: add null tolerance + field availability pre-check (skipped status) - SkillRunner: baseline data merge for follow-up events + field availability check - QcReportService: record-level pass rate calculation + accurate LLM XML report - iitBatchController: legacy log cleanup (eventId=null) + upsert RecordSummary - seed-iit-qc-rules: null/empty string tolerance + applicableEvents config V3.1 Architecture Design (docs only, no code changes): - QC engine V3.1 plan: 5-level data structure (CDISC ODM) + D1-D7 dimensions - Three-batch implementation strategy (A: foundation, B: bubbling, C: new engines) - Architecture team review: 4 whitepapers reviewed + feedback doc + 4 critical suggestions - CRA Agent strategy roadmap + CRA 4-tool explanation doc for clinical experts Project Member Management: - Cross-tenant member search and assignment (remove tenant restriction) - IIT project detail page enhancement with tabbed layout (KB + members) - IitProjectContext for business-side project selection - System-KB route access control adjustment for project operators Frontend: - AdminLayout sidebar menu restructure - IitLayout with project context provider - IitMemberManagePage new component - Business-side pages adapt to project context Prisma: - 2 new migrations (user-project RBAC + is_demo flag) - Schema updates for project member management Made-with: Cursor
662 lines
21 KiB
Markdown
662 lines
21 KiB
Markdown
# 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<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: 环境配置指南
|
||
|
||
#### 场景 1:SaaS 云端部署(壹证循科技)
|
||
|
||
**本地开发环境**(连接开发 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)
|
||
|
||
---
|
||
|
||
*文档结束。如有疑问请联系技术负责人。*
|
||
|