tyt-api-nest/src/users/users.service.ts

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,
};
}
}