Files
AIclinicalresearch/docs/04-开发规范/11-OSS存储开发规范.md
HaHafeng 0b29fe88b5 feat(iit): QC deep fix + V3.1 architecture plan + project member management
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
2026-03-01 15:27:05 +08:00

21 KiB
Raw Blame History

OSS 存储开发规范

版本: v1.0
创建日期: 2026-01-22
更新日期: 2026-01-22
适用范围: 全项目后端开发
优先级: P0 必须遵守


📋 目录

  1. 核心原则
  2. 目录结构规范
  3. Key 生成规则
  4. 文件命名规范
  5. API 使用规范
  6. 安全红线
  7. 最佳实践
  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 统一生成函数

import { randomUUID } from 'crypto';
import path from 'path';

/**
 * 生成 OSS 存储 Key
 * 
 * @param tenantId - 租户IDyizhengxun
 * @param userId - 用户IDnull 表示租户共享)
 * @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 使用示例

// 示例 1PKB 用户上传文献
const key1 = generateStorageKey('yizhengxun', 'user-001', 'pkb', '临床研究.pdf');
// → tenants/yizhengxun/users/user-001/pkb/9f206cc1c1ac4478.pdf

// 示例 2EKB 租户共享知识库
const key2 = generateStorageKey('yizhengxun', null, 'ekb', 'GCP指南.pdf');
// → tenants/yizhengxun/shared/ekb/a1b2c3d4e5f6g7h8.pdf

// 示例 3Tool 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"                     │
│       ↓                                                      │
│  生成带文件名的签名 URLContent-Disposition               │
│       ↓                                                      │
│  浏览器下载保存为: "Ihl 2011.pdf" ✅                         │
└─────────────────────────────────────────────────────────────┘

4.3 数据库字段设计

model Document {
  id            String   @id @default(uuid())
  filename      String   // 原始文件名(用于下载时恢复)
  storageKey    String   // OSS 存储路径
  fileUrl       String?  // 签名 URL可缓存
  fileSizeBytes Int
  // ...
}

5. API 使用规范

5.1 存储服务导入

// ✅ 推荐:使用全局单例
import { storage, staticStorage } from '@/common/storage';

// storage: 私有数据 Bucketai-clinical-data
// staticStorage: 静态资源 Bucketai-clinical-static

5.2 上传文件

// 上传到私有 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

// 方式 1不指定文件名下载为 UUID 文件名)
const url1 = storage.getUrl(storageKey);

// 方式 2指定原始文件名下载时恢复原始文件名✅ 推荐
const ossAdapter = storage as OSSAdapter;
const url2 = ossAdapter.getSignedUrl(storageKey, 3600, '原始文件名.pdf');

5.4 下载文件

// 下载文件内容
const buffer = await storage.download(storageKey);

5.5 删除文件

// 删除单个文件
await storage.delete(storageKey);

// 批量删除(仅 OSSAdapter 支持)
const ossAdapter = storage as OSSAdapter;
await ossAdapter.deleteMany([key1, key2, key3]);

5.6 检查文件存在

const exists = await storage.exists(storageKey);
if (!exists) {
  throw new Error('文件不存在');
}

6. 安全红线

🔴 红线 1生产环境必须使用内网

# ✅ 正确SAE 生产环境
OSS_INTERNAL=true

# ❌ 错误:生产环境用公网
OSS_INTERNAL=false  # 流量费用暴增!

违反后果:流量费用暴增,大文件上传卡死

🔴 红线 2私有 Bucket 绝不公开

ai-clinical-data Bucket 必须设置为 Private
绝对不能为了测试方便而开放公共读

违反后果:医疗数据泄露,合规风险

🔴 红线 3文件大小限制

文件大小 处理方式 说明
< 30MB toBuffer() 简单直接
30-50MB 两者皆可 视场景选择
> 50MB stream.pipeline 🚨 必须用流式
// Fastify 配置硬限制
fastify.register(multipart, {
  limits: {
    fileSize: 30 * 1024 * 1024  // 30MB 硬限制
  }
});

🔴 红线 4前端禁止直接访问 OSS

// ❌ 错误:前端直接使用 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 上传流程(完整示例)

// 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 下载流程(带原始文件名)

// 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 前端文件预览

// 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 自动刷新

// 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 时传入原始文件名:

ossAdapter.getSignedUrl(storageKey, 3600, '原始文件名.pdf');

Q2: 签名 URL 过期了怎么办?

原因:签名 URL 有过期时间(默认 1 小时)。

解决

  1. 前端实现自动刷新(见 7.4
  2. 长时间预览场景适当延长过期时间
  3. 用户刷新页面时重新获取

Q3: 环境配置指南

场景 1SaaS 云端部署(壹证循科技)

本地开发环境(连接开发 Bucket

# 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 生产环境

# 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私有化本地部署医疗机构

# 医疗机构服务器 .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: 如何清理测试数据?

# 临时文件OSS 生命周期规则自动清理temp/ 目录 1 天后删除)

# 测试文件:手动删除
await storage.delete('tenants/xxx/users/xxx/pkb/xxx.pdf');

Q5: 文件上传失败如何处理?

try {
  await storage.upload(storageKey, buffer);
} catch (error) {
  // 1. 记录日志
  logger.error('文件上传失败', { storageKey, error });
  
  // 2. 返回友好错误
  return reply.status(500).send({
    success: false,
    message: '文件上传失败,请重试'
  });
}

📚 相关文档


文档结束。如有疑问请联系技术负责人。