新增 B 端上传接口与列表接口,统一文件上传和分页查询能力 上传能力支持医院级数据隔离:系统管理员需显式指定医院,院内角色按登录医院自动隔离 图片上传自动压缩并转为 webp,视频上传自动转码并压缩为 mp4,普通文件按原始类型存储 增加上传目录与公开访问能力,统一输出可直接预览的访问地址 前端新增影像库页面,支持按类型筛选、关键字检索、分页浏览、在线预览与原文件访问 前端新增通用上传组件,支持在页面内复用并返回上传结果 管理后台新增影像库菜单与路由,并补充页面级角色权限控制 患者手术相关表单接入上传复用能力,支持术前资料与设备标签上传回填 新增上传模块 e2e 用例,覆盖成功路径、权限矩阵与关键失败场景 补充上传模块文档与安装依赖说明,完善工程内使用说明
226 lines
6.8 KiB
TypeScript
226 lines
6.8 KiB
TypeScript
import { spawn } from 'node:child_process';
|
||
import { mkdtemp, rm } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import ffmpegPath from 'ffmpeg-static';
|
||
import sharp from 'sharp';
|
||
import request from 'supertest';
|
||
import { Role } from '../../../src/generated/prisma/enums.js';
|
||
import {
|
||
closeE2EContext,
|
||
createE2EContext,
|
||
type E2EContext,
|
||
} from '../helpers/e2e-context.helper.js';
|
||
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
||
import {
|
||
expectErrorEnvelope,
|
||
expectSuccessEnvelope,
|
||
} from '../helpers/e2e-http.helper.js';
|
||
|
||
describe('BUploadsController (e2e)', () => {
|
||
let ctx: E2EContext;
|
||
let tempDir = '';
|
||
let sampleVideoPath = '';
|
||
let samplePngBuffer: Buffer;
|
||
|
||
beforeAll(async () => {
|
||
ctx = await createE2EContext();
|
||
tempDir = await mkdtemp(join(tmpdir(), 'upload-assets-e2e-'));
|
||
sampleVideoPath = join(tempDir, 'ct-video.mov');
|
||
await createSampleVideo(sampleVideoPath);
|
||
samplePngBuffer = await sharp({
|
||
create: {
|
||
width: 32,
|
||
height: 32,
|
||
channels: 3,
|
||
background: { r: 24, g: 46, b: 82 },
|
||
},
|
||
})
|
||
.png()
|
||
.toBuffer();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await closeE2EContext(ctx);
|
||
await rm(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('成功:HOSPITAL_ADMIN 可上传图片', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||
.attach('file', samplePngBuffer, {
|
||
filename: 'ct-image.png',
|
||
contentType: 'image/png',
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.type).toBe('IMAGE');
|
||
expect(response.body.data.mimeType).toBe('image/webp');
|
||
expect(response.body.data.fileName).toMatch(/^\d{14}-ct-image\.webp$/);
|
||
expect(response.body.data.storagePath).toMatch(
|
||
/^\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-image\.webp$/,
|
||
);
|
||
expect(response.body.data.url).toMatch(
|
||
/^\/uploads\/\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-image\.webp$/,
|
||
);
|
||
expect(response.body.data.hospital.id).toBe(ctx.fixtures.hospitalAId);
|
||
});
|
||
|
||
it('成功:HOSPITAL_ADMIN 可上传视频并转为 mp4', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||
.attach('file', sampleVideoPath, {
|
||
filename: 'ct-video.mov',
|
||
contentType: 'video/quicktime',
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.type).toBe('VIDEO');
|
||
expect(response.body.data.mimeType).toBe('video/mp4');
|
||
expect(response.body.data.fileName).toMatch(/^\d{14}-ct-video\.mp4$/);
|
||
expect(response.body.data.storagePath).toMatch(
|
||
/^\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-video\.mp4$/,
|
||
);
|
||
expect(response.body.data.url).toMatch(
|
||
/^\/uploads\/\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-video\.mp4$/,
|
||
);
|
||
});
|
||
|
||
it('失败:SYSTEM_ADMIN 上传文件时未指定医院返回 400', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||
.attach('file', samplePngBuffer, {
|
||
filename: 'ct-image.png',
|
||
contentType: 'image/png',
|
||
});
|
||
|
||
expectErrorEnvelope(response, 400, '系统管理员上传文件时必须显式指定 hospitalId');
|
||
});
|
||
|
||
it('失败:DIRECTOR 查询影像库返回 403', async () => {
|
||
await request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||
.attach('file', samplePngBuffer, {
|
||
filename: 'seed-image.png',
|
||
contentType: 'image/png',
|
||
});
|
||
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/b/uploads')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
||
|
||
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||
});
|
||
|
||
it('角色矩阵:上传允许系统/医院/主任/组长/医生,工程师 403,未登录 401', async () => {
|
||
await assertRoleMatrix({
|
||
name: 'POST /b/uploads role matrix',
|
||
tokens: ctx.tokens,
|
||
expectedStatusByRole: {
|
||
[Role.SYSTEM_ADMIN]: 400,
|
||
[Role.HOSPITAL_ADMIN]: 201,
|
||
[Role.DIRECTOR]: 201,
|
||
[Role.LEADER]: 201,
|
||
[Role.DOCTOR]: 201,
|
||
[Role.ENGINEER]: 403,
|
||
},
|
||
sendAsRole: async (role, token) => {
|
||
const req = request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.set('Authorization', `Bearer ${token}`)
|
||
.attach('file', samplePngBuffer, {
|
||
filename: `${role.toLowerCase()}.png`,
|
||
contentType: 'image/png',
|
||
});
|
||
|
||
if (role === Role.SYSTEM_ADMIN) {
|
||
return req;
|
||
}
|
||
return req;
|
||
},
|
||
sendWithoutToken: async () =>
|
||
request(ctx.app.getHttpServer())
|
||
.post('/b/uploads')
|
||
.attach('file', samplePngBuffer, {
|
||
filename: 'anonymous.png',
|
||
contentType: 'image/png',
|
||
}),
|
||
});
|
||
});
|
||
|
||
it('角色矩阵:上传列表仅系统/医院管理员可访问,其他角色 403,未登录 401', async () => {
|
||
await assertRoleMatrix({
|
||
name: 'GET /b/uploads role matrix',
|
||
tokens: ctx.tokens,
|
||
expectedStatusByRole: {
|
||
[Role.SYSTEM_ADMIN]: 200,
|
||
[Role.HOSPITAL_ADMIN]: 200,
|
||
[Role.DIRECTOR]: 403,
|
||
[Role.LEADER]: 403,
|
||
[Role.DOCTOR]: 403,
|
||
[Role.ENGINEER]: 403,
|
||
},
|
||
sendAsRole: async (role, token) => {
|
||
const req = request(ctx.app.getHttpServer())
|
||
.get('/b/uploads')
|
||
.set('Authorization', `Bearer ${token}`);
|
||
if (role === Role.SYSTEM_ADMIN) {
|
||
req.query({ hospitalId: ctx.fixtures.hospitalAId });
|
||
}
|
||
return req;
|
||
},
|
||
sendWithoutToken: async () => request(ctx.app.getHttpServer()).get('/b/uploads'),
|
||
});
|
||
});
|
||
});
|
||
|
||
async function createSampleVideo(outputPath: string) {
|
||
const command = ffmpegPath as unknown as string | null;
|
||
if (!command) {
|
||
throw new Error('ffmpeg-static not available');
|
||
}
|
||
|
||
const args = [
|
||
'-y',
|
||
'-f',
|
||
'lavfi',
|
||
'-i',
|
||
'color=c=black:s=320x240:d=1',
|
||
'-f',
|
||
'lavfi',
|
||
'-i',
|
||
'anullsrc=r=44100:cl=stereo',
|
||
'-shortest',
|
||
'-c:v',
|
||
'libx264',
|
||
'-pix_fmt',
|
||
'yuv420p',
|
||
'-c:a',
|
||
'aac',
|
||
'-movflags',
|
||
'+faststart',
|
||
outputPath,
|
||
];
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
const child = spawn(command, args);
|
||
let stderr = '';
|
||
|
||
child.stderr?.on('data', (chunk) => {
|
||
stderr += String(chunk);
|
||
});
|
||
child.on('error', reject);
|
||
child.on('close', (code) => {
|
||
if (code === 0) {
|
||
resolve();
|
||
return;
|
||
}
|
||
reject(new Error(stderr.trim() || 'failed to create sample video'));
|
||
});
|
||
});
|
||
}
|