新增 B 端上传接口与列表接口,统一文件上传和分页查询能力 上传能力支持医院级数据隔离:系统管理员需显式指定医院,院内角色按登录医院自动隔离 图片上传自动压缩并转为 webp,视频上传自动转码并压缩为 mp4,普通文件按原始类型存储 增加上传目录与公开访问能力,统一输出可直接预览的访问地址 前端新增影像库页面,支持按类型筛选、关键字检索、分页浏览、在线预览与原文件访问 前端新增通用上传组件,支持在页面内复用并返回上传结果 管理后台新增影像库菜单与路由,并补充页面级角色权限控制 患者手术相关表单接入上传复用能力,支持术前资料与设备标签上传回填 新增上传模块 e2e 用例,覆盖成功路径、权限矩阵与关键失败场景 补充上传模块文档与安装依赖说明,完善工程内使用说明
393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
import request from 'supertest';
|
||
import { DeviceStatus, 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,
|
||
uniquePhone,
|
||
uniqueSeedValue,
|
||
} from '../helpers/e2e-http.helper.js';
|
||
|
||
function uniqueIdCard() {
|
||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
|
||
.replace(/\D/g, '')
|
||
.slice(-4);
|
||
return `11010119990101${suffix.padStart(4, '0')}`;
|
||
}
|
||
|
||
describe('Patients Controllers (e2e)', () => {
|
||
let ctx: E2EContext;
|
||
|
||
beforeAll(async () => {
|
||
ctx = await createE2EContext();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await closeE2EContext(ctx);
|
||
});
|
||
|
||
describe('GET /b/patients', () => {
|
||
it('成功:按角色返回正确可见性范围', async () => {
|
||
const systemAdminResponse = await request(ctx.app.getHttpServer())
|
||
.get('/b/patients')
|
||
.query({ hospitalId: ctx.fixtures.hospitalAId })
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||
expectSuccessEnvelope(systemAdminResponse, 200);
|
||
const systemPatientIds = (
|
||
systemAdminResponse.body.data as Array<{ id: number }>
|
||
).map((item) => item.id);
|
||
expect(systemPatientIds).toEqual(
|
||
expect.arrayContaining([
|
||
ctx.fixtures.patients.patientA1Id,
|
||
ctx.fixtures.patients.patientA2Id,
|
||
ctx.fixtures.patients.patientA3Id,
|
||
]),
|
||
);
|
||
|
||
const hospitalAdminResponse = await request(ctx.app.getHttpServer())
|
||
.get('/b/patients')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||
expectSuccessEnvelope(hospitalAdminResponse, 200);
|
||
const hospitalPatientIds = (
|
||
hospitalAdminResponse.body.data as Array<{ id: number }>
|
||
).map((item) => item.id);
|
||
expect(hospitalPatientIds).toEqual(
|
||
expect.arrayContaining([
|
||
ctx.fixtures.patients.patientA1Id,
|
||
ctx.fixtures.patients.patientA2Id,
|
||
ctx.fixtures.patients.patientA3Id,
|
||
]),
|
||
);
|
||
|
||
const directorResponse = await request(ctx.app.getHttpServer())
|
||
.get('/b/patients')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
||
expectSuccessEnvelope(directorResponse, 200);
|
||
const directorPatientIds = (
|
||
directorResponse.body.data as Array<{ id: number }>
|
||
).map((item) => item.id);
|
||
expect(directorPatientIds).toEqual(
|
||
expect.arrayContaining([
|
||
ctx.fixtures.patients.patientA1Id,
|
||
ctx.fixtures.patients.patientA2Id,
|
||
]),
|
||
);
|
||
expect(directorPatientIds).not.toContain(
|
||
ctx.fixtures.patients.patientA3Id,
|
||
);
|
||
|
||
const leaderResponse = await request(ctx.app.getHttpServer())
|
||
.get('/b/patients')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`);
|
||
expectSuccessEnvelope(leaderResponse, 200);
|
||
const leaderPatientIds = (
|
||
leaderResponse.body.data as Array<{ id: number }>
|
||
).map((item) => item.id);
|
||
expect(leaderPatientIds).toEqual(
|
||
expect.arrayContaining([
|
||
ctx.fixtures.patients.patientA1Id,
|
||
ctx.fixtures.patients.patientA2Id,
|
||
]),
|
||
);
|
||
expect(leaderPatientIds).not.toContain(ctx.fixtures.patients.patientA3Id);
|
||
|
||
const doctorResponse = await request(ctx.app.getHttpServer())
|
||
.get('/b/patients')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||
expectSuccessEnvelope(doctorResponse, 200);
|
||
const doctorPatientIds = (
|
||
doctorResponse.body.data as Array<{ id: number }>
|
||
).map((item) => item.id);
|
||
expect(doctorPatientIds).toContain(ctx.fixtures.patients.patientA1Id);
|
||
expect(doctorPatientIds).not.toContain(ctx.fixtures.patients.patientA2Id);
|
||
expect(doctorPatientIds).not.toContain(ctx.fixtures.patients.patientA3Id);
|
||
|
||
const engineerResponse = await request(ctx.app.getHttpServer())
|
||
.get('/b/patients')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`);
|
||
expectErrorEnvelope(engineerResponse, 403, '无权限执行当前操作');
|
||
});
|
||
|
||
it('失败:SYSTEM_ADMIN 不传 hospitalId 返回 400', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/b/patients')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||
|
||
expectErrorEnvelope(
|
||
response,
|
||
400,
|
||
'系统管理员查询必须显式传入 hospitalId',
|
||
);
|
||
});
|
||
|
||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,ENGINEER 403,未登录 401', async () => {
|
||
await assertRoleMatrix({
|
||
name: 'GET /b/patients role matrix',
|
||
tokens: ctx.tokens,
|
||
expectedStatusByRole: {
|
||
[Role.SYSTEM_ADMIN]: 200,
|
||
[Role.HOSPITAL_ADMIN]: 200,
|
||
[Role.DIRECTOR]: 200,
|
||
[Role.LEADER]: 200,
|
||
[Role.DOCTOR]: 200,
|
||
[Role.ENGINEER]: 403,
|
||
},
|
||
sendAsRole: async (role, token) => {
|
||
const req = request(ctx.app.getHttpServer())
|
||
.get('/b/patients')
|
||
.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/patients'),
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('GET /c/patients/lifecycle', () => {
|
||
it('成功:已登录用户可按 phone + idCard 查询跨院生命周期', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/c/patients/lifecycle')
|
||
.query({
|
||
phone: '13800002001',
|
||
idCard: '110101199001010011',
|
||
})
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||
|
||
expectSuccessEnvelope(response, 200);
|
||
expect(response.body.data.phone).toBe('13800002001');
|
||
expect(response.body.data.idCard).toBe('110101199001010011');
|
||
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
|
||
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
|
||
});
|
||
|
||
it('失败:参数缺失返回 400', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/c/patients/lifecycle')
|
||
.query({
|
||
phone: '13800002001',
|
||
})
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||
|
||
expectErrorEnvelope(response, 400, 'idCard 必须是字符串');
|
||
});
|
||
|
||
it('失败:不存在患者返回 404', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.get('/c/patients/lifecycle')
|
||
.query({
|
||
phone: '13800009999',
|
||
idCard: '110101199009090099',
|
||
})
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||
|
||
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
|
||
});
|
||
});
|
||
|
||
describe('患者手术录入', () => {
|
||
it('成功:DOCTOR 可创建患者并附带首台手术和植入设备', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/patients')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
name: '首术患者',
|
||
inpatientNo: uniqueSeedValue('zyh'),
|
||
projectName: '脑积水手术项目',
|
||
phone: uniquePhone(),
|
||
idCard: uniqueIdCard(),
|
||
doctorId: ctx.fixtures.users.doctorAId,
|
||
initialSurgery: {
|
||
surgeryDate: '2026-03-19T08:00:00.000Z',
|
||
surgeryName: '脑室腹腔分流术',
|
||
preOpPressure: 20,
|
||
primaryDisease: '梗阻性脑积水',
|
||
hydrocephalusTypes: ['交通性'],
|
||
devices: [
|
||
{
|
||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||
shuntMode: 'VPS',
|
||
proximalPunctureAreas: ['额角'],
|
||
valvePlacementSites: ['耳后'],
|
||
distalShuntDirection: '腹腔',
|
||
initialPressure: '1',
|
||
implantNotes: '首术植入',
|
||
labelImageUrl:
|
||
'https://seed.example.com/tests/first-surgery.jpg',
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
expectSuccessEnvelope(response, 201);
|
||
expect(response.body.data.creatorId).toBe(ctx.fixtures.users.doctorAId);
|
||
expect(response.body.data.creator).toEqual(
|
||
expect.objectContaining({
|
||
id: ctx.fixtures.users.doctorAId,
|
||
}),
|
||
);
|
||
expect(response.body.data.createdAt).toBeTruthy();
|
||
expect(response.body.data.shuntSurgeryCount).toBe(1);
|
||
expect(response.body.data.surgeries).toHaveLength(1);
|
||
expect(response.body.data.surgeries[0].surgeonId).toBe(
|
||
ctx.fixtures.users.doctorAId,
|
||
);
|
||
expect(response.body.data.surgeries[0].devices).toHaveLength(1);
|
||
expect(response.body.data.surgeries[0].devices[0].implantModel).toBe(
|
||
'SEED-ADJUSTABLE-VALVE',
|
||
);
|
||
});
|
||
|
||
it('成功:新增二次手术时可弃用旧设备且保留旧设备调压历史', async () => {
|
||
const createPatientResponse = await request(ctx.app.getHttpServer())
|
||
.post('/b/patients')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
name: '二次手术患者',
|
||
inpatientNo: uniqueSeedValue('zyh'),
|
||
projectName: '二次手术项目',
|
||
phone: uniquePhone(),
|
||
idCard: uniqueIdCard(),
|
||
doctorId: ctx.fixtures.users.doctorAId,
|
||
initialSurgery: {
|
||
surgeryDate: '2026-03-01T08:00:00.000Z',
|
||
surgeryName: '首次分流术',
|
||
preOpPressure: 18,
|
||
primaryDisease: '出血后脑积水',
|
||
hydrocephalusTypes: ['高压性'],
|
||
devices: [
|
||
{
|
||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||
shuntMode: 'VPS',
|
||
proximalPunctureAreas: ['额角'],
|
||
valvePlacementSites: ['耳后'],
|
||
distalShuntDirection: '腹腔',
|
||
initialPressure: '1',
|
||
implantNotes: '首术设备',
|
||
labelImageUrl:
|
||
'https://seed.example.com/tests/initial-device.jpg',
|
||
},
|
||
],
|
||
},
|
||
});
|
||
expectSuccessEnvelope(createPatientResponse, 201);
|
||
|
||
const patient = createPatientResponse.body.data as {
|
||
id: number;
|
||
devices: Array<{ id: number }>;
|
||
};
|
||
const oldDeviceId = patient.devices[0].id;
|
||
|
||
const publishResponse = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/publish')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
engineerId: ctx.fixtures.users.engineerAId,
|
||
items: [
|
||
{
|
||
deviceId: oldDeviceId,
|
||
targetPressure: '1.5',
|
||
},
|
||
],
|
||
});
|
||
expectSuccessEnvelope(publishResponse, 201);
|
||
|
||
const completeResponse = await request(ctx.app.getHttpServer())
|
||
.post('/b/tasks/complete')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||
.send({ taskId: publishResponse.body.data.id });
|
||
expectSuccessEnvelope(completeResponse, 201);
|
||
|
||
const surgeryResponse = await request(ctx.app.getHttpServer())
|
||
.post(`/b/patients/${patient.id}/surgeries`)
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
surgeryDate: '2026-03-18T08:00:00.000Z',
|
||
surgeryName: '二次翻修术',
|
||
preOpPressure: 16,
|
||
primaryDisease: '分流功能障碍',
|
||
hydrocephalusTypes: ['交通性', '高压性'],
|
||
abandonedDeviceIds: [oldDeviceId],
|
||
devices: [
|
||
{
|
||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||
shuntMode: 'VPS',
|
||
proximalPunctureAreas: ['枕角'],
|
||
valvePlacementSites: ['耳后'],
|
||
distalShuntDirection: '腹腔',
|
||
initialPressure: '1',
|
||
implantNotes: '二次手术新设备-1',
|
||
labelImageUrl: 'https://seed.example.com/tests/revision-1.jpg',
|
||
},
|
||
{
|
||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||
shuntMode: 'VPS',
|
||
proximalPunctureAreas: ['额角'],
|
||
valvePlacementSites: ['胸前'],
|
||
distalShuntDirection: '胸腔',
|
||
initialPressure: '1.5',
|
||
implantNotes: '二次手术新设备-2',
|
||
labelImageUrl: 'https://seed.example.com/tests/revision-2.jpg',
|
||
},
|
||
],
|
||
});
|
||
|
||
expectSuccessEnvelope(surgeryResponse, 201);
|
||
expect(surgeryResponse.body.data.devices).toHaveLength(2);
|
||
expect(surgeryResponse.body.data.shuntSurgeryCount).toBe(2);
|
||
|
||
const oldDevice = await ctx.prisma.device.findUnique({
|
||
where: { id: oldDeviceId },
|
||
include: { taskItems: true },
|
||
});
|
||
|
||
expect(oldDevice?.isAbandoned).toBe(true);
|
||
expect(oldDevice?.status).toBe(DeviceStatus.INACTIVE);
|
||
expect(oldDevice?.taskItems).toHaveLength(1);
|
||
});
|
||
|
||
it('失败:手术录入设备不允许手工传 currentPressure', async () => {
|
||
const response = await request(ctx.app.getHttpServer())
|
||
.post('/b/patients')
|
||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||
.send({
|
||
name: '非法当前压力患者',
|
||
inpatientNo: uniqueSeedValue('zyh'),
|
||
projectName: '非法字段校验',
|
||
phone: uniquePhone(),
|
||
idCard: uniqueIdCard(),
|
||
doctorId: ctx.fixtures.users.doctorAId,
|
||
initialSurgery: {
|
||
surgeryDate: '2026-03-19T08:00:00.000Z',
|
||
surgeryName: '脑室腹腔分流术',
|
||
primaryDisease: '梗阻性脑积水',
|
||
hydrocephalusTypes: ['交通性'],
|
||
devices: [
|
||
{
|
||
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
|
||
shuntMode: 'VPS',
|
||
proximalPunctureAreas: ['额角'],
|
||
valvePlacementSites: ['耳后'],
|
||
distalShuntDirection: '腹腔',
|
||
initialPressure: '1',
|
||
currentPressure: '1',
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
expectErrorEnvelope(response, 400, '请求参数不合法');
|
||
});
|
||
});
|
||
});
|