feat: 手机号与微信登录鉴权改造,完善用户CRUD与权限控制
This commit is contained in:
parent
3cd7a044ca
commit
0024562863
34
prisma/migrations/20260312095251/migration.sql
Normal file
34
prisma/migrations/20260312095251/migration.sql
Normal file
@ -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");
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
70
src/auth/auth.controller.ts
Normal file
70
src/auth/auth.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
31
src/auth/auth.module.ts
Normal file
31
src/auth/auth.module.ts
Normal file
@ -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 {}
|
||||
290
src/auth/auth.service.ts
Normal file
290
src/auth/auth.service.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
2
src/auth/constants.ts
Normal file
2
src/auth/constants.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const ROLES_KEY = 'roles';
|
||||
11
src/auth/decorators/current-user.decorator.ts
Normal file
11
src/auth/decorators/current-user.decorator.ts
Normal file
@ -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;
|
||||
},
|
||||
);
|
||||
4
src/auth/decorators/public.decorator.ts
Normal file
4
src/auth/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { IS_PUBLIC_KEY } from '../constants.js';
|
||||
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
5
src/auth/decorators/roles.decorator.ts
Normal file
5
src/auth/decorators/roles.decorator.ts
Normal file
@ -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);
|
||||
19
src/auth/dto/bind-wechat.dto.ts
Normal file
19
src/auth/dto/bind-wechat.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
15
src/auth/dto/change-password.dto.ts
Normal file
15
src/auth/dto/change-password.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
9
src/auth/dto/login-mini-program.dto.ts
Normal file
9
src/auth/dto/login-mini-program.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginMiniProgramDto {
|
||||
// 小程序 openId 由前端/网关在登录态中传入。
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
@MaxLength(128)
|
||||
miniOpenId: string;
|
||||
}
|
||||
9
src/auth/dto/login-official-account.dto.ts
Normal file
9
src/auth/dto/login-official-account.dto.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginOfficialAccountDto {
|
||||
// 服务号 openId 由前端/网关在登录态中传入。
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
@MaxLength(128)
|
||||
officialOpenId: string;
|
||||
}
|
||||
13
src/auth/dto/login-phone.dto.ts
Normal file
13
src/auth/dto/login-phone.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
62
src/auth/dto/register.dto.ts
Normal file
62
src/auth/dto/register.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
84
src/auth/guards/auth.guard.ts
Normal file
84
src/auth/guards/auth.guard.ts
Normal file
@ -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<boolean> {
|
||||
// 被 @Public 标记的接口直接放行。
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 读取 Authorization: Bearer <token>。
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<Request & { user?: AuthUser }>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
41
src/auth/guards/roles.guard.ts
Normal file
41
src/auth/guards/roles.guard.ts
Normal file
@ -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<UserRole[]>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
33
src/auth/password.service.ts
Normal file
33
src/auth/password.service.ts
Normal file
@ -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<string> {
|
||||
// 每个密码生成独立盐值,抵抗彩虹表攻击。
|
||||
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<boolean> {
|
||||
// 从数据库格式中拆出盐值与哈希。
|
||||
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);
|
||||
}
|
||||
}
|
||||
108
src/auth/token.service.ts
Normal file
108
src/auth/token.service.ts
Normal file
@ -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<TokenPayload>(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<T>(encoded: string): T {
|
||||
try {
|
||||
return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as T;
|
||||
} catch {
|
||||
// 解析异常统一转成鉴权异常,避免泄露内部细节。
|
||||
throw new UnauthorizedException('token 解析失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/auth/types/auth-user.type.ts
Normal file
10
src/auth/types/auth-user.type.ts
Normal file
@ -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;
|
||||
}
|
||||
10
src/prisma.module.ts
Normal file
10
src/prisma.module.ts
Normal file
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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<boolean> {
|
||||
// 系统管理员总是有权限。
|
||||
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<number>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user