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