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
21 KiB
21 KiB
OSS 存储开发规范
版本: v1.0
创建日期: 2026-01-22
更新日期: 2026-01-22
适用范围: 全项目后端开发
优先级: ⭐⭐⭐⭐⭐ P0 必须遵守
📋 目录
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 |
用户私有 | |
| RVW 审稿系统 | rvw |
Word、PDF | 用户私有 |
| SSA 统计分析 | ssa |
Excel | 用户私有 |
| EKB 企业知识库 | ekb |
租户共享 | |
| 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 - 租户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 使用示例
// 示例 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 数据库字段设计
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: 私有数据 Bucket(ai-clinical-data)
// staticStorage: 静态资源 Bucket(ai-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 小时)。
解决:
- 前端实现自动刷新(见 7.4)
- 长时间预览场景适当延长过期时间
- 用户刷新页面时重新获取
Q3: 环境配置指南
场景 1:SaaS 云端部署(壹证循科技)
本地开发环境(连接开发 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: '文件上传失败,请重试'
});
}
📚 相关文档
文档结束。如有疑问请联系技术负责人。