prisma.config.ts 新增 seed 命令:node --env-file=.env --loader ts-node/esm prisma/seed.ts

各 DTO 增加 Swagger ApiProperty/ApiPropertyOptional 描述与示例(尤其 phone/password)
seed.ts 为新增完整 seed 脚本(幂等 upsert + 角色样例 + 患者 + 工程师分配)
This commit is contained in:
EL 2026-03-12 18:50:03 +08:00
parent 0024562863
commit 569d827b78
7 changed files with 429 additions and 0 deletions

View File

@ -7,6 +7,7 @@ export default defineConfig({
schema: "prisma/schema.prisma", schema: "prisma/schema.prisma",
migrations: { migrations: {
path: "prisma/migrations", path: "prisma/migrations",
seed: "node --env-file=.env --loader ts-node/esm prisma/seed.ts",
}, },
datasource: { datasource: {
url: process.env["DATABASE_URL"], url: process.env["DATABASE_URL"],

301
prisma/seed.ts Normal file
View File

@ -0,0 +1,301 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { randomBytes, scrypt } from 'node:crypto';
import { promisify } from 'node:util';
import { PrismaClient } from '../src/generated/prisma/client.js';
import { UserRole } from '../src/generated/prisma/enums.js';
const scryptAsync = promisify(scrypt);
const TEST_PASSWORD = 'Test123456';
const HOSPITAL_CODE = 'DEMO_HOSP_001';
const HOSPITAL_NAME = 'Demo Hospital';
const DEPARTMENT_NAME = 'Cardiology';
const MEDICAL_GROUP_NAME = 'Group A';
interface SeedUserInput {
phone: string;
name: string;
role: UserRole;
hospitalId: number | null;
departmentId: number | null;
medicalGroupId: number | null;
managerId: number | null;
}
interface PatientInput {
name: string;
hospitalId: number;
departmentId: number | null;
medicalGroupId: number | null;
doctorId: number;
}
function requireDatabaseUrl(): string {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error('DATABASE_URL is required. Run with `node --env-file=.env ...`.');
}
return url;
}
async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString('hex');
const derivedKey = (await scryptAsync(password, salt, 64)) as Buffer;
return `${salt}:${derivedKey.toString('hex')}`;
}
async function upsertUser(
prisma: PrismaClient,
user: SeedUserInput,
passwordHash: string,
) {
return prisma.user.upsert({
where: { phone: user.phone },
create: {
phone: user.phone,
passwordHash,
name: user.name,
role: user.role,
hospitalId: user.hospitalId,
departmentId: user.departmentId,
medicalGroupId: user.medicalGroupId,
managerId: user.managerId,
isActive: true,
},
update: {
passwordHash,
name: user.name,
role: user.role,
hospitalId: user.hospitalId,
departmentId: user.departmentId,
medicalGroupId: user.medicalGroupId,
managerId: user.managerId,
isActive: true,
},
select: {
id: true,
role: true,
phone: true,
name: true,
},
});
}
async function upsertPatientByNaturalKey(
prisma: PrismaClient,
patient: PatientInput,
) {
const existing = await prisma.patient.findFirst({
where: {
name: patient.name,
hospitalId: patient.hospitalId,
doctorId: patient.doctorId,
},
select: { id: true },
});
if (existing) {
return prisma.patient.update({
where: { id: existing.id },
data: {
departmentId: patient.departmentId,
medicalGroupId: patient.medicalGroupId,
},
select: { id: true, name: true },
});
}
return prisma.patient.create({
data: patient,
select: { id: true, name: true },
});
}
async function main() {
const adapter = new PrismaPg({ connectionString: requireDatabaseUrl() });
const prisma = new PrismaClient({ adapter });
try {
const passwordHash = await hashPassword(TEST_PASSWORD);
const hospital = await prisma.hospital.upsert({
where: { code: HOSPITAL_CODE },
create: {
code: HOSPITAL_CODE,
name: HOSPITAL_NAME,
},
update: {
name: HOSPITAL_NAME,
},
select: { id: true, name: true, code: true },
});
const department = await prisma.department.upsert({
where: {
hospitalId_name: {
hospitalId: hospital.id,
name: DEPARTMENT_NAME,
},
},
create: {
name: DEPARTMENT_NAME,
hospitalId: hospital.id,
},
update: {},
select: { id: true, name: true },
});
const medicalGroup = await prisma.medicalGroup.upsert({
where: {
departmentId_name: {
departmentId: department.id,
name: MEDICAL_GROUP_NAME,
},
},
create: {
name: MEDICAL_GROUP_NAME,
departmentId: department.id,
},
update: {},
select: { id: true, name: true },
});
const systemAdmin = await upsertUser(
prisma,
{
phone: '+8613800000001',
name: 'System Admin',
role: UserRole.SYSTEM_ADMIN,
hospitalId: null,
departmentId: null,
medicalGroupId: null,
managerId: null,
},
passwordHash,
);
const hospitalAdmin = await upsertUser(
prisma,
{
phone: '+8613800000002',
name: 'Hospital Admin',
role: UserRole.HOSPITAL_ADMIN,
hospitalId: hospital.id,
departmentId: null,
medicalGroupId: null,
managerId: null,
},
passwordHash,
);
const director = await upsertUser(
prisma,
{
phone: '+8613800000003',
name: 'Director',
role: UserRole.DIRECTOR,
hospitalId: hospital.id,
departmentId: department.id,
medicalGroupId: null,
managerId: hospitalAdmin.id,
},
passwordHash,
);
const teamLead = await upsertUser(
prisma,
{
phone: '+8613800000004',
name: 'Team Lead',
role: UserRole.TEAM_LEAD,
hospitalId: hospital.id,
departmentId: department.id,
medicalGroupId: medicalGroup.id,
managerId: director.id,
},
passwordHash,
);
const doctor = await upsertUser(
prisma,
{
phone: '+8613800000005',
name: 'Doctor',
role: UserRole.DOCTOR,
hospitalId: hospital.id,
departmentId: department.id,
medicalGroupId: medicalGroup.id,
managerId: teamLead.id,
},
passwordHash,
);
const engineer = await upsertUser(
prisma,
{
phone: '+8613800000006',
name: 'Engineer',
role: UserRole.ENGINEER,
hospitalId: hospital.id,
departmentId: null,
medicalGroupId: null,
managerId: hospitalAdmin.id,
},
passwordHash,
);
await upsertPatientByNaturalKey(prisma, {
name: 'Patient Alpha',
hospitalId: hospital.id,
departmentId: department.id,
medicalGroupId: medicalGroup.id,
doctorId: doctor.id,
});
await upsertPatientByNaturalKey(prisma, {
name: 'Patient Beta',
hospitalId: hospital.id,
departmentId: department.id,
medicalGroupId: medicalGroup.id,
doctorId: doctor.id,
});
await prisma.engineerHospitalAssignment.upsert({
where: {
hospitalId_engineerId: {
hospitalId: hospital.id,
engineerId: engineer.id,
},
},
create: {
hospitalId: hospital.id,
engineerId: engineer.id,
assignedById: systemAdmin.id,
},
update: {
assignedById: systemAdmin.id,
},
select: { id: true },
});
console.log(`Seed completed for hospital ${hospital.code} (${hospital.name}).`);
console.log('Test password for all seeded users:', TEST_PASSWORD);
console.table(
[systemAdmin, hospitalAdmin, director, teamLead, doctor, engineer].map(
(user) => ({
role: user.role,
phone: user.phone,
name: user.name ?? '',
password: TEST_PASSWORD,
}),
),
);
console.log('No mini-program or official-account openId was pre-seeded.');
} finally {
await prisma.$disconnect();
}
}
main().catch((error) => {
console.error('Seed failed:', error);
process.exit(1);
});

View File

@ -1,3 +1,4 @@
import { ApiProperty } from '@nestjs/swagger';
import { Matches } from 'class-validator'; import { Matches } from 'class-validator';
export class ChangePasswordDto { export class ChangePasswordDto {
@ -5,11 +6,19 @@ export class ChangePasswordDto {
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
message: '密码至少8位且包含字母和数字', message: '密码至少8位且包含字母和数字',
}) })
@ApiProperty({
description: 'Current password of the account.',
example: 'Test123456',
})
oldPassword: string; oldPassword: string;
// 新密码使用与注册一致的安全策略。 // 新密码使用与注册一致的安全策略。
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
message: '密码至少8位且包含字母和数字', message: '密码至少8位且包含字母和数字',
}) })
@ApiProperty({
description: 'New password, 8-64 chars with letters and numbers.',
example: 'NewTest123456',
})
newPassword: string; newPassword: string;
} }

View File

@ -1,13 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { Matches } from 'class-validator'; import { Matches } from 'class-validator';
export class LoginPhoneDto { export class LoginPhoneDto {
// 手机号登录入口字段。 // 手机号登录入口字段。
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
@ApiProperty({
description: 'Login phone number in E.164 format.',
example: '+8613800138000',
})
phone: string; phone: string;
// 登录密码,规则与注册保持一致。 // 登录密码,规则与注册保持一致。
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
message: '密码至少8位且包含字母和数字', message: '密码至少8位且包含字母和数字',
}) })
@ApiProperty({
description: 'Password must be 8-64 chars and include letters and numbers.',
example: 'Test123456',
})
password: string; password: string;
} }

View File

@ -1,3 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import {
IsInt, IsInt,
IsOptional, IsOptional,
@ -10,53 +11,89 @@ import {
export class RegisterDto { export class RegisterDto {
// 手机号是主登录标识。 // 手机号是主登录标识。
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
@ApiProperty({
description: 'Phone number used as login account (E.164 format).',
example: '+8613800138000',
})
phone: string; phone: string;
// 注册密码强度策略。 // 注册密码强度策略。
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
message: '密码至少8位且包含字母和数字', message: '密码至少8位且包含字母和数字',
}) })
@ApiProperty({
description: 'Password must be 8-64 chars and include letters and numbers.',
example: 'Test123456',
})
password: string; password: string;
// 个人展示名称。 // 个人展示名称。
@IsString() @IsString()
@IsOptional() @IsOptional()
@MaxLength(64) @MaxLength(64)
@ApiPropertyOptional({
description: 'Display name.',
example: 'Demo Doctor',
})
name?: string; name?: string;
// 组织归属:医院。 // 组织归属:医院。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Hospital id to bind during registration.',
example: 1,
})
hospitalId?: number; hospitalId?: number;
// 组织归属:科室。 // 组织归属:科室。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Department id to bind during registration.',
example: 1,
})
departmentId?: number; departmentId?: number;
// 组织归属:小组。 // 组织归属:小组。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Medical group id to bind during registration.',
example: 1,
})
medicalGroupId?: number; medicalGroupId?: number;
// 直属上级用户 ID。 // 直属上级用户 ID。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Direct manager user id.',
example: 2,
})
managerId?: number; managerId?: number;
// 可选:注册时直接绑定小程序账号。 // 可选:注册时直接绑定小程序账号。
@IsString() @IsString()
@IsOptional() @IsOptional()
@MaxLength(128) @MaxLength(128)
@ApiPropertyOptional({
description: 'Optional mini-program openId.',
example: 'mini_open_id_xxx',
})
wechatMiniOpenId?: string; wechatMiniOpenId?: string;
// 可选:注册时直接绑定服务号账号。 // 可选:注册时直接绑定服务号账号。
@IsString() @IsString()
@IsOptional() @IsOptional()
@MaxLength(128) @MaxLength(128)
@ApiPropertyOptional({
description: 'Optional official-account openId.',
example: 'official_open_id_xxx',
})
wechatOfficialOpenId?: string; wechatOfficialOpenId?: string;
} }

View File

@ -1,3 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { UserRole } from '../../generated/prisma/enums.js'; import { UserRole } from '../../generated/prisma/enums.js';
import { import {
IsBoolean, IsBoolean,
@ -13,51 +14,88 @@ import {
export class CreateUserDto { export class CreateUserDto {
// 手机号作为唯一登录名。 // 手机号作为唯一登录名。
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
@ApiProperty({
description: 'User login phone number (E.164 format).',
example: '+8613800138000',
})
phone: string; phone: string;
// 管理端创建用户时直接设置初始密码。 // 管理端创建用户时直接设置初始密码。
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
message: '密码至少8位且包含字母和数字', message: '密码至少8位且包含字母和数字',
}) })
@ApiProperty({
description: 'Initial password, 8-64 chars with letters and numbers.',
example: 'Test123456',
})
password: string; password: string;
// 真实姓名用于业务展示。 // 真实姓名用于业务展示。
@IsString() @IsString()
@IsOptional() @IsOptional()
@MaxLength(64) @MaxLength(64)
@ApiPropertyOptional({
description: 'Display name.',
example: 'Demo User',
})
name?: string; name?: string;
// 未传角色时由服务端默认成 DOCTOR。 // 未传角色时由服务端默认成 DOCTOR。
@IsEnum(UserRole) @IsEnum(UserRole)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Role of the user. Defaults to DOCTOR when omitted.',
enum: UserRole,
example: UserRole.DOCTOR,
})
role?: UserRole; role?: UserRole;
// 组织归属:医院。 // 组织归属:医院。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Hospital id.',
example: 1,
})
hospitalId?: number; hospitalId?: number;
// 组织归属:科室。 // 组织归属:科室。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Department id.',
example: 1,
})
departmentId?: number; departmentId?: number;
// 组织归属:小组。 // 组织归属:小组。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Medical group id.',
example: 1,
})
medicalGroupId?: number; medicalGroupId?: number;
// 上下级关系:直属上级用户 ID。 // 上下级关系:直属上级用户 ID。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Direct manager user id.',
example: 2,
})
managerId?: number; managerId?: number;
// 是否启用账号,默认 true。 // 是否启用账号,默认 true。
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Whether the account is active. Defaults to true.',
example: true,
})
isActive?: boolean; isActive?: boolean;
} }

View File

@ -1,3 +1,4 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { UserRole } from '../../generated/prisma/enums.js'; import { UserRole } from '../../generated/prisma/enums.js';
import { import {
IsBoolean, IsBoolean,
@ -14,45 +15,78 @@ export class UpdateUserDto {
// 修改手机号。 // 修改手机号。
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'User login phone number (E.164 format).',
example: '+8613800138000',
})
phone?: string; phone?: string;
// 修改姓名。 // 修改姓名。
@IsString() @IsString()
@IsOptional() @IsOptional()
@MaxLength(64) @MaxLength(64)
@ApiPropertyOptional({
description: 'Display name.',
example: 'Updated User',
})
name?: string; name?: string;
// 修改角色(仅管理员可用)。 // 修改角色(仅管理员可用)。
@IsEnum(UserRole) @IsEnum(UserRole)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Role of the user.',
enum: UserRole,
example: UserRole.DOCTOR,
})
role?: UserRole; role?: UserRole;
// 修改医院归属(仅管理员可用)。 // 修改医院归属(仅管理员可用)。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Hospital id.',
example: 1,
})
hospitalId?: number; hospitalId?: number;
// 修改科室归属(仅管理员可用)。 // 修改科室归属(仅管理员可用)。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Department id.',
example: 1,
})
departmentId?: number; departmentId?: number;
// 修改小组归属(仅管理员可用)。 // 修改小组归属(仅管理员可用)。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Medical group id.',
example: 1,
})
medicalGroupId?: number; medicalGroupId?: number;
// 修改直属上级(仅管理员可用)。 // 修改直属上级(仅管理员可用)。
@IsInt() @IsInt()
@Min(1) @Min(1)
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Direct manager user id.',
example: 2,
})
managerId?: number; managerId?: number;
// 启停账号(仅管理员可用)。 // 启停账号(仅管理员可用)。
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
@ApiPropertyOptional({
description: 'Whether account is active.',
example: true,
})
isActive?: boolean; isActive?: boolean;
} }