diff --git a/prisma/migrations/20260312095251/migration.sql b/prisma/migrations/20260312095251/migration.sql new file mode 100644 index 0000000..cf24e61 --- /dev/null +++ b/prisma/migrations/20260312095251/migration.sql @@ -0,0 +1,34 @@ +/* + Warnings: + + - You are about to drop the column `email` on the `User` table. All the data in the column will be lost. + - A unique constraint covering the columns `[phone]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[wechatMiniOpenId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[wechatOfficialOpenId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + - Added the required column `passwordHash` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `phone` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "User_email_key"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "email", +ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "lastLoginAt" TIMESTAMP(3), +ADD COLUMN "passwordHash" TEXT NOT NULL, +ADD COLUMN "phone" TEXT NOT NULL, +ADD COLUMN "wechatMiniOpenId" TEXT, +ADD COLUMN "wechatOfficialOpenId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_wechatMiniOpenId_key" ON "User"("wechatMiniOpenId"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_wechatOfficialOpenId_key" ON "User"("wechatOfficialOpenId"); + +-- CreateIndex +CREATE INDEX "User_phone_isActive_idx" ON "User"("phone", "isActive"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a16dada..640ba43 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,6 +13,13 @@ datasource db { provider = "postgresql" } +/// 统一角色枚举: +/// - SYSTEM_ADMIN: 平台管理员 +/// - HOSPITAL_ADMIN: 医院管理员 +/// - DIRECTOR: 科室主任 +/// - TEAM_LEAD: 小组组长 +/// - DOCTOR: 医生 +/// - ENGINEER: 工程师 enum UserRole { SYSTEM_ADMIN HOSPITAL_ADMIN @@ -22,99 +29,191 @@ enum UserRole { ENGINEER } +/// 医院实体:多医院租户的顶层边界。 model Hospital { + /// 主键 ID。 id Int @id @default(autoincrement()) + /// 医院名称。 name String + /// 医院编码(唯一)。 code String @unique + /// 下属科室列表。 departments Department[] + /// 归属该医院的用户。 users User[] + /// 归属该医院的患者。 patients Patient[] + /// 该医院被分配的工程师任务关系。 engineerAssignments EngineerHospitalAssignment[] + /// 创建时间。 createdAt DateTime @default(now()) + /// 更新时间。 updatedAt DateTime @default(now()) @updatedAt } +/// 科室实体:归属于某个医院。 model Department { + /// 主键 ID。 id Int @id @default(autoincrement()) + /// 科室名称。 name String + /// 所属医院 ID。 hospitalId Int + /// 医院外键关系。 hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Cascade) + /// 下属小组。 medicalGroups MedicalGroup[] + /// 科室下用户。 users User[] + /// 科室下患者。 patients Patient[] + /// 创建时间。 createdAt DateTime @default(now()) + /// 更新时间。 updatedAt DateTime @default(now()) @updatedAt + /// 同一家医院下科室名称唯一。 @@unique([hospitalId, name]) } +/// 医疗小组实体:归属于某个科室。 model MedicalGroup { + /// 主键 ID。 id Int @id @default(autoincrement()) + /// 小组名称。 name String + /// 所属科室 ID。 departmentId Int + /// 科室外键关系。 department Department @relation(fields: [departmentId], references: [id], onDelete: Cascade) + /// 小组用户。 users User[] + /// 小组患者。 patients Patient[] + /// 创建时间。 createdAt DateTime @default(now()) + /// 更新时间。 updatedAt DateTime @default(now()) @updatedAt + /// 同一科室下小组名称唯一。 @@unique([departmentId, name]) } +/// 用户实体:统一承载组织关系、登录凭证与上下级结构。 model User { + /// 主键 ID。 id Int @id @default(autoincrement()) - email String @unique + /// 手机号(唯一登录名)。 + phone String @unique + /// 密码哈希(禁止存明文)。 + passwordHash String + /// 用户姓名。 name String? + /// 用户角色。 role UserRole @default(DOCTOR) + /// 归属医院 ID(可空,支持平台角色)。 hospitalId Int? + /// 归属科室 ID(可空)。 departmentId Int? + /// 归属小组 ID(可空)。 medicalGroupId Int? + /// 上级用户 ID(自关联层级)。 managerId Int? + /// 小程序 openId(可空,唯一)。 + wechatMiniOpenId String? @unique + /// 服务号 openId(可空,唯一)。 + wechatOfficialOpenId String? @unique + /// 账号是否启用。 + isActive Boolean @default(true) + /// 最近登录时间。 + lastLoginAt DateTime? + /// 医院关系。 hospital Hospital? @relation(fields: [hospitalId], references: [id], onDelete: SetNull) + /// 科室关系。 department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) + /// 小组关系。 medicalGroup MedicalGroup? @relation(fields: [medicalGroupId], references: [id], onDelete: SetNull) + /// 上级关系。 manager User? @relation("UserHierarchy", fields: [managerId], references: [id], onDelete: SetNull) + /// 下级关系。 subordinates User[] @relation("UserHierarchy") + /// 医生持有患者关系。 patients Patient[] @relation("DoctorPatients") + /// 工程师被分配医院关系。 engineerAssignments EngineerHospitalAssignment[] @relation("EngineerAssignments") + /// 系统管理员分配记录关系。 assignedEngineerHospitals EngineerHospitalAssignment[] @relation("SystemAdminAssignments") + /// 创建时间。 createdAt DateTime @default(now()) + /// 更新时间。 updatedAt DateTime @default(now()) @updatedAt + /// 角色索引,便于权限查询。 @@index([role]) + /// 医院索引,便于分院查询。 @@index([hospitalId]) + /// 上级索引,便于层级查询。 @@index([managerId]) + /// 手机号 + 启用状态联合索引,便于登录场景查询。 + @@index([phone, isActive]) } +/// 患者实体:医生直接持有患者,上级通过层级可见性获取。 model Patient { + /// 主键 ID。 id Int @id @default(autoincrement()) + /// 患者姓名。 name String + /// 所属医院 ID。 hospitalId Int + /// 所属科室 ID(可空)。 departmentId Int? + /// 所属小组 ID(可空)。 medicalGroupId Int? + /// 负责医生 ID。 doctorId Int + /// 医院关系。 hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Restrict) + /// 科室关系。 department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull) + /// 小组关系。 medicalGroup MedicalGroup? @relation(fields: [medicalGroupId], references: [id], onDelete: SetNull) + /// 负责医生关系。 doctor User @relation("DoctorPatients", fields: [doctorId], references: [id], onDelete: Restrict) + /// 创建时间。 createdAt DateTime @default(now()) + /// 更新时间。 updatedAt DateTime @default(now()) @updatedAt + /// 医生索引,便于查“医生名下患者”。 @@index([doctorId]) + /// 医院索引,便于查“全院患者”。 @@index([hospitalId]) } +/// 工程师任务分配关系:只有系统管理员可以分配工程师到医院。 model EngineerHospitalAssignment { + /// 主键 ID。 id Int @id @default(autoincrement()) + /// 医院 ID。 hospitalId Int + /// 工程师用户 ID。 engineerId Int + /// 分配人(系统管理员)用户 ID。 assignedById Int + /// 医院关系。 hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Cascade) + /// 工程师关系。 engineer User @relation("EngineerAssignments", fields: [engineerId], references: [id], onDelete: Restrict) + /// 分配人关系。 assignedBy User @relation("SystemAdminAssignments", fields: [assignedById], references: [id], onDelete: Restrict) + /// 创建时间。 createdAt DateTime @default(now()) + /// 同一医院与工程师不能重复分配。 @@unique([hospitalId, engineerId]) + /// 工程师索引。 @@index([engineerId]) + /// 分配人索引。 @@index([assignedById]) } diff --git a/src/app.module.ts b/src/app.module.ts index c37608d..daaf693 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { AuthModule } from './auth/auth.module.js'; import { UsersModule } from './users/users.module.js'; @Module({ - imports: [ConfigModule.forRoot(), UsersModule], + // ConfigModule 先加载,保证鉴权和数据库都可读取环境变量。 + imports: [ConfigModule.forRoot(), AuthModule, UsersModule], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..982e069 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,70 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { CurrentUser } from './decorators/current-user.decorator.js'; +import { Public } from './decorators/public.decorator.js'; +import { AuthService } from './auth.service.js'; +import { BindWechatDto } from './dto/bind-wechat.dto.js'; +import { ChangePasswordDto } from './dto/change-password.dto.js'; +import { LoginMiniProgramDto } from './dto/login-mini-program.dto.js'; +import { LoginOfficialAccountDto } from './dto/login-official-account.dto.js'; +import { LoginPhoneDto } from './dto/login-phone.dto.js'; +import { RegisterDto } from './dto/register.dto.js'; +import type { AuthUser } from './types/auth-user.type.js'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + // 公开注册接口:手机号 + 密码。 + @Public() + @Post('register') + register(@Body() registerDto: RegisterDto) { + return this.authService.register(registerDto); + } + + // 公开登录接口:手机号 + 密码。 + @Public() + @Post('login/phone') + loginWithPhone(@Body() loginPhoneDto: LoginPhoneDto) { + return this.authService.loginWithPhone(loginPhoneDto); + } + + // 公开登录接口:小程序 openId。 + @Public() + @Post('login/mini-program') + loginWithMiniProgram(@Body() loginMiniProgramDto: LoginMiniProgramDto) { + return this.authService.loginWithMiniProgram(loginMiniProgramDto); + } + + // 公开登录接口:服务号 openId。 + @Public() + @Post('login/official-account') + loginWithOfficialAccount( + @Body() loginOfficialAccountDto: LoginOfficialAccountDto, + ) { + return this.authService.loginWithOfficialAccount(loginOfficialAccountDto); + } + + // 登录后获取当前用户信息。 + @Get('me') + me(@CurrentUser() currentUser: AuthUser) { + return this.authService.me(currentUser.id); + } + + // 登录后绑定小程序/服务号账号。 + @Post('bind/wechat') + bindWechat( + @CurrentUser() currentUser: AuthUser, + @Body() bindWechatDto: BindWechatDto, + ) { + return this.authService.bindWechat(currentUser.id, bindWechatDto); + } + + // 登录后修改密码。 + @Post('change-password') + changePassword( + @CurrentUser() currentUser: AuthUser, + @Body() changePasswordDto: ChangePasswordDto, + ) { + return this.authService.changePassword(currentUser.id, changePasswordDto); + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..4d229f2 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { PrismaModule } from '../prisma.module.js'; +import { AuthController } from './auth.controller.js'; +import { AuthService } from './auth.service.js'; +import { PasswordService } from './password.service.js'; +import { TokenService } from './token.service.js'; +import { AuthGuard } from './guards/auth.guard.js'; +import { RolesGuard } from './guards/roles.guard.js'; + +@Module({ + imports: [PrismaModule], + controllers: [AuthController], + providers: [ + AuthService, + PasswordService, + TokenService, + // 全局鉴权:默认所有接口都需要登录,除非显式标记 @Public。 + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + // 全局角色守卫:只有使用 @Roles 的接口才会进行角色判断。 + { + provide: APP_GUARD, + useClass: RolesGuard, + }, + ], + exports: [PasswordService], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..eb1ead2 --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,290 @@ +import { + ConflictException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { UserRole } from '../generated/prisma/enums.js'; +import { PrismaService } from '../prisma.service.js'; +import { BindWechatDto } from './dto/bind-wechat.dto.js'; +import { ChangePasswordDto } from './dto/change-password.dto.js'; +import { LoginMiniProgramDto } from './dto/login-mini-program.dto.js'; +import { LoginOfficialAccountDto } from './dto/login-official-account.dto.js'; +import { LoginPhoneDto } from './dto/login-phone.dto.js'; +import { RegisterDto } from './dto/register.dto.js'; +import { PasswordService } from './password.service.js'; +import { TokenService } from './token.service.js'; + +@Injectable() +export class AuthService { + // 查询用户时统一选择字段,避免在多处重复定义。 + private readonly userSelect = { + id: true, + phone: true, + name: true, + role: true, + hospitalId: true, + departmentId: true, + medicalGroupId: true, + managerId: true, + wechatMiniOpenId: true, + wechatOfficialOpenId: true, + isActive: true, + lastLoginAt: true, + createdAt: true, + updatedAt: true, + } as const; + + constructor( + private readonly prisma: PrismaService, + private readonly passwordService: PasswordService, + private readonly tokenService: TokenService, + ) {} + + async register(registerDto: RegisterDto) { + // 自助注册只允许创建普通医生角色,防止越权注册管理员。 + const existingUser = await this.prisma.user.findUnique({ + where: { phone: registerDto.phone }, + select: { id: true }, + }); + if (existingUser) { + throw new ConflictException('手机号已注册'); + } + + const passwordHash = await this.passwordService.hashPassword(registerDto.password); + const user = await this.prisma.user.create({ + data: { + phone: registerDto.phone, + passwordHash, + name: registerDto.name, + role: UserRole.DOCTOR, + hospitalId: registerDto.hospitalId, + departmentId: registerDto.departmentId, + medicalGroupId: registerDto.medicalGroupId, + managerId: registerDto.managerId, + wechatMiniOpenId: registerDto.wechatMiniOpenId, + wechatOfficialOpenId: registerDto.wechatOfficialOpenId, + isActive: true, + }, + select: this.userSelect, + }); + + return this.issueLoginResult(user); + } + + async loginWithPhone(loginPhoneDto: LoginPhoneDto) { + // 手机号登录需要读出密码哈希并做安全校验。 + const user = await this.prisma.user.findUnique({ + where: { phone: loginPhoneDto.phone }, + select: { + id: true, + passwordHash: true, + isActive: true, + }, + }); + if (!user || !user.isActive) { + throw new UnauthorizedException('手机号或密码错误'); + } + + const isPasswordValid = await this.passwordService.verifyPassword( + loginPhoneDto.password, + user.passwordHash, + ); + if (!isPasswordValid) { + throw new UnauthorizedException('手机号或密码错误'); + } + + return this.loginByUserId(user.id); + } + + async loginWithMiniProgram(loginMiniProgramDto: LoginMiniProgramDto) { + // 小程序登录通过 miniOpenId 直连用户。 + const user = await this.prisma.user.findFirst({ + where: { + wechatMiniOpenId: loginMiniProgramDto.miniOpenId, + isActive: true, + }, + select: { id: true }, + }); + if (!user) { + throw new UnauthorizedException('小程序账号未绑定'); + } + return this.loginByUserId(user.id); + } + + async loginWithOfficialAccount(loginOfficialAccountDto: LoginOfficialAccountDto) { + // 服务号登录通过 officialOpenId 直连用户。 + const user = await this.prisma.user.findFirst({ + where: { + wechatOfficialOpenId: loginOfficialAccountDto.officialOpenId, + isActive: true, + }, + select: { id: true }, + }); + if (!user) { + throw new UnauthorizedException('服务号账号未绑定'); + } + return this.loginByUserId(user.id); + } + + async bindWechat(userId: number, bindWechatDto: BindWechatDto) { + // 绑定之前先做冲突检查,确保一个 openId 只归属一个用户。 + if (bindWechatDto.miniOpenId) { + const existingMini = await this.prisma.user.findFirst({ + where: { + wechatMiniOpenId: bindWechatDto.miniOpenId, + NOT: { id: userId }, + }, + select: { id: true }, + }); + if (existingMini) { + throw new ConflictException('小程序账号已被其他用户绑定'); + } + } + + if (bindWechatDto.officialOpenId) { + const existingOfficial = await this.prisma.user.findFirst({ + where: { + wechatOfficialOpenId: bindWechatDto.officialOpenId, + NOT: { id: userId }, + }, + select: { id: true }, + }); + if (existingOfficial) { + throw new ConflictException('服务号账号已被其他用户绑定'); + } + } + + const updatedUser = await this.prisma.user.update({ + where: { id: userId }, + data: { + wechatMiniOpenId: bindWechatDto.miniOpenId ?? undefined, + wechatOfficialOpenId: bindWechatDto.officialOpenId ?? undefined, + }, + select: this.userSelect, + }); + return this.toUserView(updatedUser); + } + + async changePassword(userId: number, changePasswordDto: ChangePasswordDto) { + // 改密必须验证旧密码,防止被盗登录态直接改密。 + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + isActive: true, + passwordHash: true, + }, + }); + if (!user || !user.isActive) { + throw new NotFoundException('用户不存在'); + } + + const isOldPasswordValid = await this.passwordService.verifyPassword( + changePasswordDto.oldPassword, + user.passwordHash, + ); + if (!isOldPasswordValid) { + throw new UnauthorizedException('旧密码不正确'); + } + + const newPasswordHash = await this.passwordService.hashPassword( + changePasswordDto.newPassword, + ); + await this.prisma.user.update({ + where: { id: userId }, + data: { passwordHash: newPasswordHash }, + select: { id: true }, + }); + + return { message: '密码修改成功' }; + } + + async me(userId: number) { + // 读取当前用户公开信息,不返回密码和 openId 明文。 + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: this.userSelect, + }); + if (!user) { + throw new NotFoundException('用户不存在'); + } + return this.toUserView(user); + } + + private async loginByUserId(userId: number) { + // 登录成功后更新最后登录时间,便于安全审计。 + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { lastLoginAt: new Date() }, + select: this.userSelect, + }); + return this.issueLoginResult(user); + } + + private issueLoginResult(user: { + id: number; + role: UserRole; + hospitalId: number | null; + wechatMiniOpenId: string | null; + wechatOfficialOpenId: string | null; + phone: string; + name: string | null; + departmentId: number | null; + medicalGroupId: number | null; + managerId: number | null; + isActive: boolean; + lastLoginAt: Date | null; + createdAt: Date; + updatedAt: Date; + }) { + // token 中保留最小必要信息,其余数据走数据库校验。 + const accessToken = this.tokenService.sign({ + sub: user.id, + role: user.role, + hospitalId: user.hospitalId, + }); + + return { + tokenType: 'Bearer', + accessToken, + expiresIn: this.tokenService.expiresInSeconds, + user: this.toUserView(user), + }; + } + + private toUserView(user: { + id: number; + phone: string; + name: string | null; + role: UserRole; + hospitalId: number | null; + departmentId: number | null; + medicalGroupId: number | null; + managerId: number | null; + wechatMiniOpenId: string | null; + wechatOfficialOpenId: string | null; + isActive: boolean; + lastLoginAt: Date | null; + createdAt: Date; + updatedAt: Date; + }) { + // 输出层做脱敏:不回传 openId 原文,只回传是否已绑定。 + return { + id: user.id, + phone: user.phone, + name: user.name, + role: user.role, + hospitalId: user.hospitalId, + departmentId: user.departmentId, + medicalGroupId: user.medicalGroupId, + managerId: user.managerId, + isActive: user.isActive, + lastLoginAt: user.lastLoginAt, + wechatMiniLinked: Boolean(user.wechatMiniOpenId), + wechatOfficialLinked: Boolean(user.wechatOfficialOpenId), + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + } +} diff --git a/src/auth/constants.ts b/src/auth/constants.ts new file mode 100644 index 0000000..e70c029 --- /dev/null +++ b/src/auth/constants.ts @@ -0,0 +1,2 @@ +export const IS_PUBLIC_KEY = 'isPublic'; +export const ROLES_KEY = 'roles'; diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..244ad06 --- /dev/null +++ b/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,11 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { AuthUser } from '../types/auth-user.type.js'; + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthUser => { + const request = ctx + .switchToHttp() + .getRequest<{ user: AuthUser }>(); + return request.user; + }, +); diff --git a/src/auth/decorators/public.decorator.ts b/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..41c6635 --- /dev/null +++ b/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; +import { IS_PUBLIC_KEY } from '../constants.js'; + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/auth/decorators/roles.decorator.ts b/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..fa456eb --- /dev/null +++ b/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import type { UserRole } from '../../generated/prisma/enums.js'; +import { ROLES_KEY } from '../constants.js'; + +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/auth/dto/bind-wechat.dto.ts b/src/auth/dto/bind-wechat.dto.ts new file mode 100644 index 0000000..5284377 --- /dev/null +++ b/src/auth/dto/bind-wechat.dto.ts @@ -0,0 +1,19 @@ +import { IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator'; + +export class BindWechatDto { + // 两个字段至少传一个:如果未传 officialOpenId,则 miniOpenId 必填。 + @ValidateIf((o: BindWechatDto) => !o.officialOpenId) + @IsString() + @MinLength(6) + @MaxLength(128) + @IsOptional() + miniOpenId?: string; + + // 两个字段至少传一个:如果未传 miniOpenId,则 officialOpenId 必填。 + @ValidateIf((o: BindWechatDto) => !o.miniOpenId) + @IsString() + @MinLength(6) + @MaxLength(128) + @IsOptional() + officialOpenId?: string; +} diff --git a/src/auth/dto/change-password.dto.ts b/src/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..78a832e --- /dev/null +++ b/src/auth/dto/change-password.dto.ts @@ -0,0 +1,15 @@ +import { Matches } from 'class-validator'; + +export class ChangePasswordDto { + // 老密码用于确认操作者身份。 + @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { + message: '密码至少8位,且包含字母和数字', + }) + oldPassword: string; + + // 新密码使用与注册一致的安全策略。 + @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { + message: '密码至少8位,且包含字母和数字', + }) + newPassword: string; +} diff --git a/src/auth/dto/login-mini-program.dto.ts b/src/auth/dto/login-mini-program.dto.ts new file mode 100644 index 0000000..3f9a7c4 --- /dev/null +++ b/src/auth/dto/login-mini-program.dto.ts @@ -0,0 +1,9 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class LoginMiniProgramDto { + // 小程序 openId 由前端/网关在登录态中传入。 + @IsString() + @MinLength(6) + @MaxLength(128) + miniOpenId: string; +} diff --git a/src/auth/dto/login-official-account.dto.ts b/src/auth/dto/login-official-account.dto.ts new file mode 100644 index 0000000..a396f0d --- /dev/null +++ b/src/auth/dto/login-official-account.dto.ts @@ -0,0 +1,9 @@ +import { IsString, MaxLength, MinLength } from 'class-validator'; + +export class LoginOfficialAccountDto { + // 服务号 openId 由前端/网关在登录态中传入。 + @IsString() + @MinLength(6) + @MaxLength(128) + officialOpenId: string; +} diff --git a/src/auth/dto/login-phone.dto.ts b/src/auth/dto/login-phone.dto.ts new file mode 100644 index 0000000..34f80c9 --- /dev/null +++ b/src/auth/dto/login-phone.dto.ts @@ -0,0 +1,13 @@ +import { Matches } from 'class-validator'; + +export class LoginPhoneDto { + // 手机号登录入口字段。 + @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) + phone: string; + + // 登录密码,规则与注册保持一致。 + @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { + message: '密码至少8位,且包含字母和数字', + }) + password: string; +} diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts new file mode 100644 index 0000000..644712d --- /dev/null +++ b/src/auth/dto/register.dto.ts @@ -0,0 +1,62 @@ +import { + IsInt, + IsOptional, + IsString, + Matches, + MaxLength, + Min, +} from 'class-validator'; + +export class RegisterDto { + // 手机号是主登录标识。 + @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) + phone: string; + + // 注册密码强度策略。 + @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { + message: '密码至少8位,且包含字母和数字', + }) + password: string; + + // 个人展示名称。 + @IsString() + @IsOptional() + @MaxLength(64) + name?: string; + + // 组织归属:医院。 + @IsInt() + @Min(1) + @IsOptional() + hospitalId?: number; + + // 组织归属:科室。 + @IsInt() + @Min(1) + @IsOptional() + departmentId?: number; + + // 组织归属:小组。 + @IsInt() + @Min(1) + @IsOptional() + medicalGroupId?: number; + + // 直属上级用户 ID。 + @IsInt() + @Min(1) + @IsOptional() + managerId?: number; + + // 可选:注册时直接绑定小程序账号。 + @IsString() + @IsOptional() + @MaxLength(128) + wechatMiniOpenId?: string; + + // 可选:注册时直接绑定服务号账号。 + @IsString() + @IsOptional() + @MaxLength(128) + wechatOfficialOpenId?: string; +} diff --git a/src/auth/guards/auth.guard.ts b/src/auth/guards/auth.guard.ts new file mode 100644 index 0000000..23a2f91 --- /dev/null +++ b/src/auth/guards/auth.guard.ts @@ -0,0 +1,84 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { PrismaService } from '../../prisma.service.js'; +import { IS_PUBLIC_KEY } from '../constants.js'; +import { TokenService } from '../token.service.js'; +import type { AuthUser } from '../types/auth-user.type.js'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly tokenService: TokenService, + private readonly prisma: PrismaService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // 被 @Public 标记的接口直接放行。 + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + + // 读取 Authorization: Bearer 。 + const request = context + .switchToHttp() + .getRequest(); + const token = this.extractBearerToken(request.headers.authorization); + if (!token) { + throw new UnauthorizedException('未登录'); + } + + // 验签成功后仍需到数据库核验账号状态。 + const payload = this.tokenService.verify(token); + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + select: { + id: true, + role: true, + hospitalId: true, + departmentId: true, + medicalGroupId: true, + managerId: true, + isActive: true, + }, + }); + + if (!user || !user.isActive) { + throw new UnauthorizedException('账号不可用'); + } + + // 把当前用户挂载到 request,供后续 decorator/业务层使用。 + request.user = { + id: user.id, + role: user.role, + hospitalId: user.hospitalId, + departmentId: user.departmentId, + medicalGroupId: user.medicalGroupId, + managerId: user.managerId, + }; + return true; + } + + private extractBearerToken(header?: string): string | null { + // Header 不存在直接视为未登录。 + if (!header) { + return null; + } + + const [type, token] = header.split(' '); + if (type !== 'Bearer' || !token) { + return null; + } + return token; + } +} diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..a8f500c --- /dev/null +++ b/src/auth/guards/roles.guard.ts @@ -0,0 +1,41 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import type { UserRole } from '../../generated/prisma/enums.js'; +import { ROLES_KEY } from '../constants.js'; +import type { AuthUser } from '../types/auth-user.type.js'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + // 未声明 @Roles 的接口不做角色限制。 + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + // 角色守卫依赖 AuthGuard 注入 request.user。 + const request = context + .switchToHttp() + .getRequest<{ user?: AuthUser }>(); + const user = request.user; + if (!user) { + throw new UnauthorizedException('未登录'); + } + // 当前角色不在白名单则拒绝访问。 + if (!requiredRoles.includes(user.role)) { + throw new ForbiddenException('权限不足'); + } + return true; + } +} diff --git a/src/auth/password.service.ts b/src/auth/password.service.ts new file mode 100644 index 0000000..a1c6072 --- /dev/null +++ b/src/auth/password.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { randomBytes, scrypt, timingSafeEqual } from 'crypto'; +import { promisify } from 'util'; + +@Injectable() +export class PasswordService { + // 使用 Node.js 原生 scrypt,避免引入额外原生依赖。 + private readonly scryptAsync = promisify(scrypt); + + async hashPassword(password: string): Promise { + // 每个密码生成独立盐值,抵抗彩虹表攻击。 + const salt = randomBytes(16).toString('hex'); + const derivedKey = (await this.scryptAsync(password, salt, 64)) as Buffer; + // 持久化格式:salt:hash。 + return `${salt}:${derivedKey.toString('hex')}`; + } + + async verifyPassword(password: string, hashedPassword: string): Promise { + // 从数据库格式中拆出盐值与哈希。 + const [salt, keyHex] = hashedPassword.split(':'); + if (!salt || !keyHex) { + return false; + } + + // 使用同样参数重新推导哈希并做常量时间比较。 + const derivedKey = (await this.scryptAsync(password, salt, 64)) as Buffer; + const storedKey = Buffer.from(keyHex, 'hex'); + if (storedKey.length !== derivedKey.length) { + return false; + } + return timingSafeEqual(storedKey, derivedKey); + } +} diff --git a/src/auth/token.service.ts b/src/auth/token.service.ts new file mode 100644 index 0000000..df1fd2c --- /dev/null +++ b/src/auth/token.service.ts @@ -0,0 +1,108 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { createHmac, timingSafeEqual } from 'crypto'; +import type { UserRole } from '../generated/prisma/enums.js'; + +interface JwtHeader { + alg: 'HS256'; + typ: 'JWT'; +} + +interface SignTokenInput { + sub: number; + role: UserRole; + hospitalId: number | null; +} + +export interface TokenPayload extends SignTokenInput { + iat: number; + exp: number; +} + +@Injectable() +export class TokenService { + // 建议在生产环境通过环境变量覆盖,且长度不少于 32 位。 + private readonly secret = + process.env.JWT_SECRET ?? + 'local-dev-insecure-secret-change-me-please-1234567890'; + // 默认 24 小时过期,可通过环境变量调节。 + readonly expiresInSeconds = Number.parseInt( + process.env.JWT_EXPIRES_IN_SECONDS ?? '86400', + 10, + ); + + constructor() { + // 启动时校验配置,避免线上运行才暴露错误。 + if (this.secret.length < 32) { + throw new Error('JWT_SECRET 长度至少32位'); + } + if (!Number.isFinite(this.expiresInSeconds) || this.expiresInSeconds <= 0) { + throw new Error('JWT_EXPIRES_IN_SECONDS 必须是正整数'); + } + } + + sign(input: SignTokenInput): string { + // 生成 iat/exp,形成完整 payload。 + const now = Math.floor(Date.now() / 1000); + const payload: TokenPayload = { + ...input, + iat: now, + exp: now + this.expiresInSeconds, + }; + const header: JwtHeader = { + alg: 'HS256', + typ: 'JWT', + }; + + // HMAC-SHA256 签名,输出标准三段式 token。 + const encodedHeader = this.encodeObject(header); + const encodedPayload = this.encodeObject(payload); + const unsignedToken = `${encodedHeader}.${encodedPayload}`; + const signature = this.signRaw(unsignedToken); + return `${unsignedToken}.${signature}`; + } + + verify(token: string): TokenPayload { + // 必须是 header.payload.signature 三段。 + const parts = token.split('.'); + if (parts.length !== 3) { + throw new UnauthorizedException('无效 token'); + } + + // 重新计算签名并做常量时间比较,防止签名篡改。 + const [encodedHeader, encodedPayload, signature] = parts; + const unsignedToken = `${encodedHeader}.${encodedPayload}`; + const expectedSignature = this.signRaw(unsignedToken); + if ( + signature.length !== expectedSignature.length || + !timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)) + ) { + throw new UnauthorizedException('token 签名错误'); + } + + // 解析 payload 并做过期检查。 + const payload = this.decodeObject(encodedPayload); + const now = Math.floor(Date.now() / 1000); + if (!payload.sub || !payload.role || !payload.exp || payload.exp <= now) { + throw new UnauthorizedException('token 已过期'); + } + return payload; + } + + private signRaw(content: string): string { + // 统一签名算法,便于后续切换实现。 + return createHmac('sha256', this.secret).update(content).digest('base64url'); + } + + private encodeObject(value: object): string { + return Buffer.from(JSON.stringify(value)).toString('base64url'); + } + + private decodeObject(encoded: string): T { + try { + return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as T; + } catch { + // 解析异常统一转成鉴权异常,避免泄露内部细节。 + throw new UnauthorizedException('token 解析失败'); + } + } +} diff --git a/src/auth/types/auth-user.type.ts b/src/auth/types/auth-user.type.ts new file mode 100644 index 0000000..ce3906a --- /dev/null +++ b/src/auth/types/auth-user.type.ts @@ -0,0 +1,10 @@ +import type { UserRole } from '../../generated/prisma/enums.js'; + +export interface AuthUser { + id: number; + role: UserRole; + hospitalId: number | null; + departmentId: number | null; + medicalGroupId: number | null; + managerId: number | null; +} diff --git a/src/prisma.module.ts b/src/prisma.module.ts new file mode 100644 index 0000000..4192d8f --- /dev/null +++ b/src/prisma.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service.js'; + +@Module({ + // PrismaService 作为全局可复用数据访问层。 + providers: [PrismaService], + // 导出后其它模块可直接注入 PrismaService。 + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index 6ae1277..9b9d740 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -1,34 +1,63 @@ import { UserRole } from '../../generated/prisma/enums.js'; -import { IsEmail, IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import { + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + Matches, + MaxLength, + Min, +} from 'class-validator'; export class CreateUserDto { - @IsEmail() - email: string; + // 手机号作为唯一登录名。 + @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) + phone: string; + // 管理端创建用户时直接设置初始密码。 + @Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, { + message: '密码至少8位,且包含字母和数字', + }) + password: string; + + // 真实姓名用于业务展示。 @IsString() @IsOptional() + @MaxLength(64) name?: string; + // 未传角色时由服务端默认成 DOCTOR。 @IsEnum(UserRole) - role: UserRole; + @IsOptional() + role?: UserRole; + // 组织归属:医院。 @IsInt() @Min(1) @IsOptional() hospitalId?: number; + // 组织归属:科室。 @IsInt() @Min(1) @IsOptional() departmentId?: number; + // 组织归属:小组。 @IsInt() @Min(1) @IsOptional() medicalGroupId?: number; + // 上下级关系:直属上级用户 ID。 @IsInt() @Min(1) @IsOptional() managerId?: number; + + // 是否启用账号,默认 true。 + @IsBoolean() + @IsOptional() + isActive?: boolean; } diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index b4b2f3f..9f7565e 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -1,4 +1,58 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreateUserDto } from './create-user.dto.js'; +import { UserRole } from '../../generated/prisma/enums.js'; +import { + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsString, + Matches, + MaxLength, + Min, +} from 'class-validator'; -export class UpdateUserDto extends PartialType(CreateUserDto) {} +export class UpdateUserDto { + // 修改手机号。 + @Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' }) + @IsOptional() + phone?: string; + + // 修改姓名。 + @IsString() + @IsOptional() + @MaxLength(64) + name?: string; + + // 修改角色(仅管理员可用)。 + @IsEnum(UserRole) + @IsOptional() + role?: UserRole; + + // 修改医院归属(仅管理员可用)。 + @IsInt() + @Min(1) + @IsOptional() + hospitalId?: number; + + // 修改科室归属(仅管理员可用)。 + @IsInt() + @Min(1) + @IsOptional() + departmentId?: number; + + // 修改小组归属(仅管理员可用)。 + @IsInt() + @Min(1) + @IsOptional() + medicalGroupId?: number; + + // 修改直属上级(仅管理员可用)。 + @IsInt() + @Min(1) + @IsOptional() + managerId?: number; + + // 启停账号(仅管理员可用)。 + @IsBoolean() + @IsOptional() + isActive?: boolean; +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 435e543..483b32a 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,12 +1,17 @@ import { - Controller, - Get, - Post, Body, - Patch, - Param, + Controller, Delete, + Get, + Param, + Patch, + ParseIntPipe, + Post, } from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator.js'; +import { Roles } from '../auth/decorators/roles.decorator.js'; +import type { AuthUser } from '../auth/types/auth-user.type.js'; +import { UserRole } from '../generated/prisma/enums.js'; import { UsersService } from './users.service.js'; import { CreateUserDto } from './dto/create-user.dto.js'; import { UpdateUserDto } from './dto/update-user.dto.js'; @@ -15,28 +20,48 @@ import { UpdateUserDto } from './dto/update-user.dto.js'; export class UsersController { constructor(private readonly usersService: UsersService) {} + // 仅系统管理员和医院管理员可以创建用户。 + @Roles(UserRole.SYSTEM_ADMIN, UserRole.HOSPITAL_ADMIN) @Post() - create(@Body() createUserDto: CreateUserDto) { - return this.usersService.create(createUserDto); + create( + @CurrentUser() currentUser: AuthUser, + @Body() createUserDto: CreateUserDto, + ) { + return this.usersService.create(currentUser, createUserDto); } + // 列表接口会根据当前角色自动过滤可见范围。 @Get() - findAll() { - return this.usersService.findAll(); + findAll(@CurrentUser() currentUser: AuthUser) { + return this.usersService.findAll(currentUser); } + // 单个详情同样走可见范围校验。 @Get(':id') - findOne(@Param('id') id: string) { - return this.usersService.findOne(+id); + findOne( + @CurrentUser() currentUser: AuthUser, + @Param('id', ParseIntPipe) id: number, + ) { + return this.usersService.findOne(currentUser, id); } + // 更新接口支持管理员更新他人、普通用户更新自己(受字段限制)。 @Patch(':id') - update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { - return this.usersService.update(+id, updateUserDto); + update( + @CurrentUser() currentUser: AuthUser, + @Param('id', ParseIntPipe) id: number, + @Body() updateUserDto: UpdateUserDto, + ) { + return this.usersService.update(currentUser, id, updateUserDto); } + // 删除采用“禁用账号”方式,避免误删关联业务数据。 + @Roles(UserRole.SYSTEM_ADMIN, UserRole.HOSPITAL_ADMIN) @Delete(':id') - remove(@Param('id') id: string) { - return this.usersService.remove(+id); + remove( + @CurrentUser() currentUser: AuthUser, + @Param('id', ParseIntPipe) id: number, + ) { + return this.usersService.remove(currentUser, id); } } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 5b27469..fd83003 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,10 +1,14 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service.js'; import { UsersController } from './users.controller.js'; -import { PrismaService } from '../prisma.service.js'; +import { PrismaModule } from '../prisma.module.js'; +import { PasswordService } from '../auth/password.service.js'; @Module({ + // 复用 Prisma 单例,避免每个模块重复实例化客户端。 + imports: [PrismaModule], controllers: [UsersController], - providers: [UsersService, PrismaService], + // UsersService 依赖 PasswordService 来处理管理员创建用户时的密码哈希。 + providers: [UsersService, PasswordService], }) export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 8b0e751..4377f01 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,50 +1,405 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { AuthUser } from '../auth/types/auth-user.type.js'; +import { PasswordService } from '../auth/password.service.js'; +import { UserRole } from '../generated/prisma/enums.js'; import { CreateUserDto } from './dto/create-user.dto.js'; import { UpdateUserDto } from './dto/update-user.dto.js'; import { PrismaService } from '../prisma.service.js'; @Injectable() export class UsersService { - constructor(private prisma: PrismaService) {} - async create(createUserDto: CreateUserDto) { - // 判断用户是否存在 - const { email } = createUserDto; - const existingUser = await this.prisma.user.findUnique({ - where: { - email: email, - }, - }); + // 统一定义用户输出字段,避免泄露密码哈希与 openId 明文。 + private readonly userSelect = { + id: true, + phone: true, + name: true, + role: true, + hospitalId: true, + departmentId: true, + medicalGroupId: true, + managerId: true, + wechatMiniOpenId: true, + wechatOfficialOpenId: true, + isActive: true, + lastLoginAt: true, + createdAt: true, + updatedAt: true, + } as const; - if (existingUser) { - throw new HttpException('邮箱重复', HttpStatus.CONFLICT); + constructor( + private readonly prisma: PrismaService, + private readonly passwordService: PasswordService, + ) {} + + async create(currentUser: AuthUser, createUserDto: CreateUserDto) { + // 只有系统管理员和医院管理员可以创建用户。 + if (!this.isAdmin(currentUser.role)) { + throw new ForbiddenException('无创建用户权限'); } - return this.prisma.user.create({ + // 医院管理员创建用户时强制绑定本院,防止跨院越权。 + const role = createUserDto.role ?? UserRole.DOCTOR; + let hospitalId = createUserDto.hospitalId; + if (currentUser.role === UserRole.HOSPITAL_ADMIN) { + if (!currentUser.hospitalId) { + throw new ForbiddenException('当前管理员未绑定医院'); + } + if (hospitalId !== undefined && hospitalId !== currentUser.hospitalId) { + throw new ForbiddenException('医院管理员不能跨院创建用户'); + } + if (role === UserRole.SYSTEM_ADMIN) { + throw new ForbiddenException('医院管理员不能创建系统管理员'); + } + hospitalId = currentUser.hospitalId; + } + + // 手机号唯一约束用显式检查提升错误可读性。 + const existingUser = await this.prisma.user.findUnique({ + where: { phone: createUserDto.phone }, + select: { id: true }, + }); + if (existingUser) { + throw new ConflictException('手机号已存在'); + } + + // 新建用户时密码必须做哈希存储,禁止明文落库。 + const passwordHash = await this.passwordService.hashPassword( + createUserDto.password, + ); + await this.assertManagerValid(createUserDto.managerId, hospitalId ?? null); + + const createdUser = await this.prisma.user.create({ data: { - email: createUserDto.email, + phone: createUserDto.phone, + passwordHash, name: createUserDto.name, - role: createUserDto.role, - hospitalId: createUserDto.hospitalId, + role, + hospitalId, departmentId: createUserDto.departmentId, medicalGroupId: createUserDto.medicalGroupId, managerId: createUserDto.managerId, + isActive: createUserDto.isActive ?? true, }, + select: this.userSelect, }); + return this.toUserView(createdUser); } - findAll() { - return this.prisma.user.findMany(); + async findAll(currentUser: AuthUser) { + // 按角色动态构造可见范围,避免在 controller 中散落权限判断。 + const where = await this.buildVisibilityWhere(currentUser); + const users = await this.prisma.user.findMany({ + where, + select: this.userSelect, + orderBy: { id: 'asc' }, + }); + return users.map((user) => this.toUserView(user)); } - findOne(id: number) { - return `This action returns a #${id} user`; + async findOne(currentUser: AuthUser, id: number) { + // 先查再判,便于区分不存在和无权限两类错误。 + const targetUser = await this.prisma.user.findUnique({ + where: { id }, + select: this.userSelect, + }); + if (!targetUser) { + throw new NotFoundException('用户不存在'); + } + + const canAccess = await this.canAccessUser(currentUser, targetUser); + if (!canAccess) { + throw new ForbiddenException('无访问权限'); + } + return this.toUserView(targetUser); } - update(id: number, updateUserDto: UpdateUserDto) { - return `This action updates a #${id} user`; + async update(currentUser: AuthUser, id: number, updateUserDto: UpdateUserDto) { + // 读取目标用户,用于后续做跨院和角色越权判断。 + const targetUser = await this.prisma.user.findUnique({ + where: { id }, + select: this.userSelect, + }); + if (!targetUser) { + throw new NotFoundException('用户不存在'); + } + + const canAccess = await this.canAccessUser(currentUser, targetUser); + if (!canAccess) { + throw new ForbiddenException('无更新权限'); + } + + // 非管理员只允许修改自己,且只能改基础资料。 + if (!this.isAdmin(currentUser.role)) { + if (currentUser.id !== id) { + throw new ForbiddenException('只能修改自己的资料'); + } + if ( + updateUserDto.role !== undefined || + updateUserDto.hospitalId !== undefined || + updateUserDto.departmentId !== undefined || + updateUserDto.medicalGroupId !== undefined || + updateUserDto.managerId !== undefined || + updateUserDto.isActive !== undefined + ) { + throw new ForbiddenException('无权修改组织和角色信息'); + } + } + + // 医院管理员不允许操作系统管理员,且不能把用户迁移到其他医院。 + if (currentUser.role === UserRole.HOSPITAL_ADMIN) { + if (targetUser.role === UserRole.SYSTEM_ADMIN) { + throw new ForbiddenException('医院管理员不能操作系统管理员'); + } + if ( + updateUserDto.hospitalId !== undefined && + updateUserDto.hospitalId !== currentUser.hospitalId + ) { + throw new ForbiddenException('医院管理员不能跨院修改'); + } + if (updateUserDto.role === UserRole.SYSTEM_ADMIN) { + throw new ForbiddenException('不能提升为系统管理员'); + } + } + + // 仅写入传入字段,避免误覆盖数据库已有值。 + const data: { + phone?: string; + name?: string | null; + role?: UserRole; + hospitalId?: number | null; + departmentId?: number | null; + medicalGroupId?: number | null; + managerId?: number | null; + isActive?: boolean; + } = {}; + + if (updateUserDto.phone !== undefined) { + data.phone = updateUserDto.phone; + } + if (updateUserDto.name !== undefined) { + data.name = updateUserDto.name; + } + if (updateUserDto.role !== undefined) { + data.role = updateUserDto.role; + } + if (updateUserDto.hospitalId !== undefined) { + data.hospitalId = updateUserDto.hospitalId; + } + if (updateUserDto.departmentId !== undefined) { + data.departmentId = updateUserDto.departmentId; + } + if (updateUserDto.medicalGroupId !== undefined) { + data.medicalGroupId = updateUserDto.medicalGroupId; + } + if (updateUserDto.managerId !== undefined) { + data.managerId = updateUserDto.managerId; + } + if (updateUserDto.isActive !== undefined) { + data.isActive = updateUserDto.isActive; + } + if (data.managerId !== undefined && data.managerId === id) { + throw new ForbiddenException('不能把自己设置为上级'); + } + + // 如果变更了上级,需要校验上级存在且组织关系合法。 + const nextHospitalId = data.hospitalId ?? targetUser.hospitalId; + await this.assertManagerValid(data.managerId, nextHospitalId); + + const updatedUser = await this.prisma.user.update({ + where: { id }, + data, + select: this.userSelect, + }); + return this.toUserView(updatedUser); } - remove(id: number) { - return `This action removes a #${id} user`; + async remove(currentUser: AuthUser, id: number) { + // 删除接口采用“停用账号”实现,保留审计和业务关联。 + if (!this.isAdmin(currentUser.role)) { + throw new ForbiddenException('无删除权限'); + } + if (currentUser.id === id) { + throw new ForbiddenException('不能停用自己'); + } + + const targetUser = await this.prisma.user.findUnique({ + where: { id }, + select: this.userSelect, + }); + if (!targetUser) { + throw new NotFoundException('用户不存在'); + } + + const canAccess = await this.canAccessUser(currentUser, targetUser); + if (!canAccess) { + throw new ForbiddenException('无删除权限'); + } + if ( + currentUser.role === UserRole.HOSPITAL_ADMIN && + targetUser.role === UserRole.SYSTEM_ADMIN + ) { + throw new ForbiddenException('医院管理员不能停用系统管理员'); + } + + const disabledUser = await this.prisma.user.update({ + where: { id }, + data: { isActive: false }, + select: this.userSelect, + }); + return this.toUserView(disabledUser); + } + + private async buildVisibilityWhere(currentUser: AuthUser) { + // 系统管理员可见全量用户。 + if (currentUser.role === UserRole.SYSTEM_ADMIN) { + return {}; + } + + // 医院管理员仅可见本院用户。 + if (currentUser.role === UserRole.HOSPITAL_ADMIN) { + if (!currentUser.hospitalId) { + return { id: -1 }; + } + return { hospitalId: currentUser.hospitalId }; + } + + // 主任/组长可见自己和所有下级。 + if ( + currentUser.role === UserRole.DIRECTOR || + currentUser.role === UserRole.TEAM_LEAD + ) { + const subordinateIds = await this.getSubordinateIds(currentUser.id); + return { id: { in: [currentUser.id, ...subordinateIds] } }; + } + + // 医生、工程师默认只可见自己。 + return { id: currentUser.id }; + } + + private async canAccessUser( + currentUser: AuthUser, + targetUser: { id: number; role: UserRole; hospitalId: number | null }, + ): Promise { + // 系统管理员总是有权限。 + if (currentUser.role === UserRole.SYSTEM_ADMIN) { + return true; + } + + // 医院管理员限制在本院范围。 + if (currentUser.role === UserRole.HOSPITAL_ADMIN) { + return ( + currentUser.hospitalId !== null && + targetUser.hospitalId === currentUser.hospitalId + ); + } + + // 主任/组长需要命中自己或下级链路。 + if ( + currentUser.role === UserRole.DIRECTOR || + currentUser.role === UserRole.TEAM_LEAD + ) { + if (targetUser.id === currentUser.id) { + return true; + } + const subordinateIds = await this.getSubordinateIds(currentUser.id); + return subordinateIds.includes(targetUser.id); + } + + // 普通角色仅能访问自己。 + return targetUser.id === currentUser.id; + } + + private async getSubordinateIds(rootUserId: number) { + // 使用 BFS 逐层展开下级,支持任意深度上下级关系。 + const visited = new Set(); + let frontier = [rootUserId]; + + while (frontier.length > 0) { + const rows = await this.prisma.user.findMany({ + where: { managerId: { in: frontier } }, + select: { id: true }, + }); + + const nextFrontier: number[] = []; + for (const row of rows) { + if (visited.has(row.id)) { + continue; + } + visited.add(row.id); + nextFrontier.push(row.id); + } + frontier = nextFrontier; + } + + return Array.from(visited); + } + + private isAdmin(role: UserRole) { + // 系统管理员和医院管理员都属于平台管理角色。 + return role === UserRole.SYSTEM_ADMIN || role === UserRole.HOSPITAL_ADMIN; + } + + private async assertManagerValid( + managerId: number | null | undefined, + hospitalId: number | null, + ) { + // 未设置上级时无需校验。 + if (managerId === undefined || managerId === null) { + return; + } + + // 上级用户必须存在且处于启用状态。 + const manager = await this.prisma.user.findUnique({ + where: { id: managerId }, + select: { id: true, hospitalId: true, isActive: true }, + }); + if (!manager || !manager.isActive) { + throw new NotFoundException('上级用户不存在或已停用'); + } + + // 若当前用户绑定了医院,上级也必须在同一医院。 + if (hospitalId !== null && manager.hospitalId !== hospitalId) { + throw new ForbiddenException('上级必须与用户在同一医院'); + } + } + + private toUserView(user: { + id: number; + phone: string; + name: string | null; + role: UserRole; + hospitalId: number | null; + departmentId: number | null; + medicalGroupId: number | null; + managerId: number | null; + wechatMiniOpenId: string | null; + wechatOfficialOpenId: string | null; + isActive: boolean; + lastLoginAt: Date | null; + createdAt: Date; + updatedAt: Date; + }) { + // 返回 API 时只透出是否绑定,不透出 openId 实值。 + return { + id: user.id, + phone: user.phone, + name: user.name, + role: user.role, + hospitalId: user.hospitalId, + departmentId: user.departmentId, + medicalGroupId: user.medicalGroupId, + managerId: user.managerId, + isActive: user.isActive, + lastLoginAt: user.lastLoginAt, + wechatMiniLinked: Boolean(user.wechatMiniOpenId), + wechatOfficialLinked: Boolean(user.wechatOfficialOpenId), + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; } }