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:
parent
0024562863
commit
569d827b78
@ -7,6 +7,7 @@ export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
seed: "node --env-file=.env --loader ts-node/esm prisma/seed.ts",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
|
||||
301
prisma/seed.ts
Normal file
301
prisma/seed.ts
Normal 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);
|
||||
});
|
||||
@ -1,3 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Matches } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ -5,11 +6,19 @@ export class ChangePasswordDto {
|
||||
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
||||
message: '密码至少8位,且包含字母和数字',
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'Current password of the account.',
|
||||
example: 'Test123456',
|
||||
})
|
||||
oldPassword: string;
|
||||
|
||||
// 新密码使用与注册一致的安全策略。
|
||||
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
||||
message: '密码至少8位,且包含字母和数字',
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'New password, 8-64 chars with letters and numbers.',
|
||||
example: 'NewTest123456',
|
||||
})
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
@ -1,13 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Matches } from 'class-validator';
|
||||
|
||||
export class LoginPhoneDto {
|
||||
// 手机号登录入口字段。
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
|
||||
@ApiProperty({
|
||||
description: 'Login phone number in E.164 format.',
|
||||
example: '+8613800138000',
|
||||
})
|
||||
phone: string;
|
||||
|
||||
// 登录密码,规则与注册保持一致。
|
||||
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
||||
message: '密码至少8位,且包含字母和数字',
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'Password must be 8-64 chars and include letters and numbers.',
|
||||
example: 'Test123456',
|
||||
})
|
||||
password: string;
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
@ -10,53 +11,89 @@ import {
|
||||
export class RegisterDto {
|
||||
// 手机号是主登录标识。
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
|
||||
@ApiProperty({
|
||||
description: 'Phone number used as login account (E.164 format).',
|
||||
example: '+8613800138000',
|
||||
})
|
||||
phone: string;
|
||||
|
||||
// 注册密码强度策略。
|
||||
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
||||
message: '密码至少8位,且包含字母和数字',
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'Password must be 8-64 chars and include letters and numbers.',
|
||||
example: 'Test123456',
|
||||
})
|
||||
password: string;
|
||||
|
||||
// 个人展示名称。
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(64)
|
||||
@ApiPropertyOptional({
|
||||
description: 'Display name.',
|
||||
example: 'Demo Doctor',
|
||||
})
|
||||
name?: string;
|
||||
|
||||
// 组织归属:医院。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Hospital id to bind during registration.',
|
||||
example: 1,
|
||||
})
|
||||
hospitalId?: number;
|
||||
|
||||
// 组织归属:科室。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Department id to bind during registration.',
|
||||
example: 1,
|
||||
})
|
||||
departmentId?: number;
|
||||
|
||||
// 组织归属:小组。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Medical group id to bind during registration.',
|
||||
example: 1,
|
||||
})
|
||||
medicalGroupId?: number;
|
||||
|
||||
// 直属上级用户 ID。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Direct manager user id.',
|
||||
example: 2,
|
||||
})
|
||||
managerId?: number;
|
||||
|
||||
// 可选:注册时直接绑定小程序账号。
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(128)
|
||||
@ApiPropertyOptional({
|
||||
description: 'Optional mini-program openId.',
|
||||
example: 'mini_open_id_xxx',
|
||||
})
|
||||
wechatMiniOpenId?: string;
|
||||
|
||||
// 可选:注册时直接绑定服务号账号。
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(128)
|
||||
@ApiPropertyOptional({
|
||||
description: 'Optional official-account openId.',
|
||||
example: 'official_open_id_xxx',
|
||||
})
|
||||
wechatOfficialOpenId?: string;
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { UserRole } from '../../generated/prisma/enums.js';
|
||||
import {
|
||||
IsBoolean,
|
||||
@ -13,51 +14,88 @@ import {
|
||||
export class CreateUserDto {
|
||||
// 手机号作为唯一登录名。
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
|
||||
@ApiProperty({
|
||||
description: 'User login phone number (E.164 format).',
|
||||
example: '+8613800138000',
|
||||
})
|
||||
phone: string;
|
||||
|
||||
// 管理端创建用户时直接设置初始密码。
|
||||
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
||||
message: '密码至少8位,且包含字母和数字',
|
||||
})
|
||||
@ApiProperty({
|
||||
description: 'Initial password, 8-64 chars with letters and numbers.',
|
||||
example: 'Test123456',
|
||||
})
|
||||
password: string;
|
||||
|
||||
// 真实姓名用于业务展示。
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(64)
|
||||
@ApiPropertyOptional({
|
||||
description: 'Display name.',
|
||||
example: 'Demo User',
|
||||
})
|
||||
name?: string;
|
||||
|
||||
// 未传角色时由服务端默认成 DOCTOR。
|
||||
@IsEnum(UserRole)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Role of the user. Defaults to DOCTOR when omitted.',
|
||||
enum: UserRole,
|
||||
example: UserRole.DOCTOR,
|
||||
})
|
||||
role?: UserRole;
|
||||
|
||||
// 组织归属:医院。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Hospital id.',
|
||||
example: 1,
|
||||
})
|
||||
hospitalId?: number;
|
||||
|
||||
// 组织归属:科室。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Department id.',
|
||||
example: 1,
|
||||
})
|
||||
departmentId?: number;
|
||||
|
||||
// 组织归属:小组。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Medical group id.',
|
||||
example: 1,
|
||||
})
|
||||
medicalGroupId?: number;
|
||||
|
||||
// 上下级关系:直属上级用户 ID。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Direct manager user id.',
|
||||
example: 2,
|
||||
})
|
||||
managerId?: number;
|
||||
|
||||
// 是否启用账号,默认 true。
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Whether the account is active. Defaults to true.',
|
||||
example: true,
|
||||
})
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { UserRole } from '../../generated/prisma/enums.js';
|
||||
import {
|
||||
IsBoolean,
|
||||
@ -14,45 +15,78 @@ export class UpdateUserDto {
|
||||
// 修改手机号。
|
||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'User login phone number (E.164 format).',
|
||||
example: '+8613800138000',
|
||||
})
|
||||
phone?: string;
|
||||
|
||||
// 修改姓名。
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(64)
|
||||
@ApiPropertyOptional({
|
||||
description: 'Display name.',
|
||||
example: 'Updated User',
|
||||
})
|
||||
name?: string;
|
||||
|
||||
// 修改角色(仅管理员可用)。
|
||||
@IsEnum(UserRole)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Role of the user.',
|
||||
enum: UserRole,
|
||||
example: UserRole.DOCTOR,
|
||||
})
|
||||
role?: UserRole;
|
||||
|
||||
// 修改医院归属(仅管理员可用)。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Hospital id.',
|
||||
example: 1,
|
||||
})
|
||||
hospitalId?: number;
|
||||
|
||||
// 修改科室归属(仅管理员可用)。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Department id.',
|
||||
example: 1,
|
||||
})
|
||||
departmentId?: number;
|
||||
|
||||
// 修改小组归属(仅管理员可用)。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Medical group id.',
|
||||
example: 1,
|
||||
})
|
||||
medicalGroupId?: number;
|
||||
|
||||
// 修改直属上级(仅管理员可用)。
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Direct manager user id.',
|
||||
example: 2,
|
||||
})
|
||||
managerId?: number;
|
||||
|
||||
// 启停账号(仅管理员可用)。
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
description: 'Whether account is active.',
|
||||
example: true,
|
||||
})
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user