406 lines
13 KiB
TypeScript
406 lines
13 KiB
TypeScript
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 {
|
|
// 统一定义用户输出字段,避免泄露密码哈希与 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;
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly passwordService: PasswordService,
|
|
) {}
|
|
|
|
async create(currentUser: AuthUser, createUserDto: CreateUserDto) {
|
|
// 只有系统管理员和医院管理员可以创建用户。
|
|
if (!this.isAdmin(currentUser.role)) {
|
|
throw new ForbiddenException('无创建用户权限');
|
|
}
|
|
|
|
// 医院管理员创建用户时强制绑定本院,防止跨院越权。
|
|
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: {
|
|
phone: createUserDto.phone,
|
|
passwordHash,
|
|
name: createUserDto.name,
|
|
role,
|
|
hospitalId,
|
|
departmentId: createUserDto.departmentId,
|
|
medicalGroupId: createUserDto.medicalGroupId,
|
|
managerId: createUserDto.managerId,
|
|
isActive: createUserDto.isActive ?? true,
|
|
},
|
|
select: this.userSelect,
|
|
});
|
|
return this.toUserView(createdUser);
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|