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:
@@ -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` 创建功能分支
|
||||
|
||||
@@ -330,3 +330,6 @@ npx tsx check_iit_asl_data.ts
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -198,3 +198,6 @@ interface DecodedToken {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
654
docs/04-开发规范/11-OSS存储开发规范.md
Normal file
654
docs/04-开发规范/11-OSS存储开发规范.md
Normal 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 - 租户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)
|
||||
|
||||
---
|
||||
|
||||
*文档结束。如有疑问请联系技术负责人。*
|
||||
|
||||
Reference in New Issue
Block a user