diff --git a/README.md b/README.md index 5a23149..ab32cbe 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,17 @@ docs/ ```env DATABASE_URL="postgresql://user:password@127.0.0.1:5432/tyt?schema=public" -JWT_SECRET="请替换为强随机密钥" +AUTH_TOKEN_SECRET="请替换为强随机密钥" JWT_EXPIRES_IN="7d" SYSTEM_ADMIN_BOOTSTRAP_KEY="初始化系统管理员用密钥" ``` +管理员创建链路: + +- 可通过 `POST /auth/system-admin` 创建系统管理员(需引导密钥)。 +- 系统管理员负责创建医院、系统管理员与医院管理员。 +- 医院管理员负责创建本院下级角色(主任/组长/医生/工程师)。 + ## 4. 启动流程 ```bash diff --git a/docs/auth.md b/docs/auth.md index f7274f5..6ed0b01 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -2,12 +2,12 @@ ## 1. 目标 -- 提供注册、登录、`/me` 身份查询。 +- 提供系统管理员创建、登录、`/me` 身份查询。 - 使用 JWT 做认证,Guard 做鉴权,RolesGuard 做 RBAC。 ## 2. 核心接口 -- `POST /auth/register`:注册账号(支持医生/工程师/院管等角色约束) +- `POST /auth/system-admin`:创建系统管理员(需引导密钥) - `POST /auth/login`:手机号 + 角色 + 密码登录(支持同手机号多院场景) - `GET /auth/me`:返回当前登录用户上下文 @@ -15,13 +15,13 @@ 1. `AccessTokenGuard` 从 `Authorization` 读取 Bearer Token。 2. 校验 JWT 签名与载荷字段。 -3. 载荷映射为 `ActorContext` 注入 `request.user`。 +3. 载荷映射为 `ActorContext` 注入 `request.actor`。 4. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。 ## 4. Token 约定 - Header:`Authorization: Bearer ` -- 载荷关键字段:`sub`、`role`、`hospitalId`、`departmentId`、`groupId` +- 载荷关键字段:`id`、`role`、`hospitalId`、`departmentId`、`groupId` ## 5. 错误码与中文消息 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f4ec725..ab6c429 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,23 +71,24 @@ model Group { // 用户表:支持后台密码登录与小程序 openId。 model User { - id Int @id @default(autoincrement()) - name String - phone String + id Int @id @default(autoincrement()) + name String + phone String // 后台登录密码哈希(bcrypt)。 - passwordHash String? - openId String? @unique - role Role - hospitalId Int? - departmentId Int? - groupId Int? - hospital Hospital? @relation(fields: [hospitalId], references: [id]) - department Department? @relation(fields: [departmentId], references: [id]) - group Group? @relation(fields: [groupId], references: [id]) - doctorPatients Patient[] @relation("DoctorPatients") - createdTasks Task[] @relation("TaskCreator") - acceptedTasks Task[] @relation("TaskEngineer") + passwordHash String? + openId String? @unique + role Role + hospitalId Int? + departmentId Int? + groupId Int? + hospital Hospital? @relation(fields: [hospitalId], references: [id]) + department Department? @relation(fields: [departmentId], references: [id]) + group Group? @relation(fields: [groupId], references: [id]) + doctorPatients Patient[] @relation("DoctorPatients") + createdTasks Task[] @relation("TaskCreator") + acceptedTasks Task[] @relation("TaskEngineer") + @@unique([phone, role, hospitalId]) @@index([phone]) @@index([hospitalId, role]) @@index([departmentId, role]) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 1a76ae1..0bc18e6 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,18 +1,14 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; -import { - ApiBearerAuth, - ApiOperation, - ApiTags, -} from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service.js'; -import { RegisterUserDto } from '../users/dto/register-user.dto.js'; import { LoginDto } from '../users/dto/login.dto.js'; import { AccessTokenGuard } from './access-token.guard.js'; import { CurrentActor } from './current-actor.decorator.js'; import type { ActorContext } from '../common/actor-context.js'; +import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js'; /** - * 认证控制器:提供注册、登录、获取当前登录用户信息接口。 + * 认证控制器:提供系统管理员创建、登录、获取当前登录用户信息接口。 */ @ApiTags('认证') @Controller('auth') @@ -20,12 +16,12 @@ export class AuthController { constructor(private readonly authService: AuthService) {} /** - * 注册账号。 + * 创建系统管理员(需引导密钥)。 */ - @Post('register') - @ApiOperation({ summary: '注册账号' }) - register(@Body() dto: RegisterUserDto) { - return this.authService.register(dto); + @Post('system-admin') + @ApiOperation({ summary: '创建系统管理员' }) + createSystemAdmin(@Body() dto: CreateSystemAdminDto) { + return this.authService.createSystemAdmin(dto); } /** diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 508e229..ffa1690 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import type { ActorContext } from '../common/actor-context.js'; import { UsersService } from '../users/users.service.js'; -import { RegisterUserDto } from '../users/dto/register-user.dto.js'; import { LoginDto } from '../users/dto/login.dto.js'; +import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js'; /** * 认证服务:将控制层输入转发到用户域能力,避免控制器直接操作用户仓储。 @@ -12,10 +12,10 @@ export class AuthService { constructor(private readonly usersService: UsersService) {} /** - * 注册能力委托给用户服务。 + * 系统管理员创建能力委托给用户服务。 */ - register(dto: RegisterUserDto) { - return this.usersService.register(dto); + createSystemAdmin(dto: CreateSystemAdminDto) { + return this.usersService.createSystemAdmin(dto); } /** diff --git a/src/auth/dto/create-system-admin.dto.ts b/src/auth/dto/create-system-admin.dto.ts new file mode 100644 index 0000000..75b75a9 --- /dev/null +++ b/src/auth/dto/create-system-admin.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class CreateSystemAdminDto { + @ApiProperty({ description: '姓名', example: '系统管理员' }) + @IsString({ message: 'name 必须是字符串' }) + name!: string; + + @ApiProperty({ description: '手机号', example: '13800000000' }) + @IsString({ message: 'phone 必须是字符串' }) + phone!: string; + + @ApiProperty({ description: '密码(至少 8 位)', example: 'Admin@12345' }) + @IsString({ message: 'password 必须是字符串' }) + password!: string; + + @ApiPropertyOptional({ + description: '可选微信 openId', + example: 'o123abcxyz', + }) + @IsOptional() + @IsString({ message: 'openId 必须是字符串' }) + openId?: string; + + @ApiProperty({ + description: + '系统管理员创建引导密钥(来自环境变量 SYSTEM_ADMIN_BOOTSTRAP_KEY)', + example: 'init-admin-secret', + }) + @IsString({ message: 'systemAdminBootstrapKey 必须是字符串' }) + systemAdminBootstrapKey!: string; +} diff --git a/src/common/messages.ts b/src/common/messages.ts index de81d44..71e6bd1 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -25,6 +25,7 @@ export const MESSAGES = { TOKEN_FIELD_INVALID: 'Token 中字段不合法', INVALID_CREDENTIALS: '手机号、角色或密码错误', PASSWORD_NOT_ENABLED: '该账号未启用密码登录', + REGISTER_DISABLED: '注册接口已关闭,请联系管理员创建账号', }, USER: { @@ -53,6 +54,8 @@ export const MESSAGES = { DELETE_CONFLICT: '用户存在关联患者或任务,无法删除', MULTI_ACCOUNT_REQUIRE_HOSPITAL: '检测到多个同手机号账号,请传 hospitalId 指定登录医院', + CREATE_FORBIDDEN: '当前角色无权限创建该用户', + HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号', }, TASK: { diff --git a/src/main.ts b/src/main.ts index 4a57d29..fdb7efe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,7 +10,7 @@ import { ResponseEnvelopeInterceptor } from './common/response-envelope.intercep async function bootstrap() { // 创建应用实例并加载核心模块。 const app = await NestFactory.create(AppModule); - + app.enableCors(); // 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。 app.useGlobalPipes( new ValidationPipe({ @@ -39,7 +39,7 @@ async function bootstrap() { .setTitle('TYT 多租户医疗调压系统 API') .setDescription('后端接口文档(含认证、RBAC、任务流转与患者聚合)') .setVersion('1.0.0') - .addServer('http://localhost:3000', 'localhost') + .addServer('http://192.168.0.140:3000', 'localhost') .addBearerAuth( { type: 'http', diff --git a/src/notifications/wechat-notify/wechat-notify.service.ts b/src/notifications/wechat-notify/wechat-notify.service.ts index fabe3bf..a66e576 100644 --- a/src/notifications/wechat-notify/wechat-notify.service.ts +++ b/src/notifications/wechat-notify/wechat-notify.service.ts @@ -15,7 +15,10 @@ export class WechatNotifyService { /** * 任务通知发送入口:后续可在此接入微信服务号/小程序订阅消息 API。 */ - async notifyTaskChange(openIds: Array, payload: TaskNotifyPayload) { + async notifyTaskChange( + openIds: Array, + payload: TaskNotifyPayload, + ) { const targets = Array.from( new Set( openIds @@ -32,10 +35,22 @@ export class WechatNotifyService { } for (const openId of targets) { + const maskedOpenId = this.maskOpenId(openId); // TODO: 在此处调用微信服务号/小程序消息推送 API。 this.logger.log( - `模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${openId}`, + `模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${maskedOpenId}`, ); } } + + /** + * 日志脱敏:仅保留 openId 首尾片段,避免完整标识泄露。 + */ + private maskOpenId(openId: string) { + if (openId.length <= 6) { + return '***'; + } + + return `${openId.slice(0, 3)}***${openId.slice(-3)}`; + } } diff --git a/src/patients/c-patients/c-patients.controller.ts b/src/patients/c-patients/c-patients.controller.ts index c307128..1f19fa3 100644 --- a/src/patients/c-patients/c-patients.controller.ts +++ b/src/patients/c-patients/c-patients.controller.ts @@ -1,5 +1,11 @@ -import { Controller, Get, Query } from '@nestjs/common'; -import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { AccessTokenGuard } from '../../auth/access-token.guard.js'; import { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js'; import { CPatientsService } from './c-patients.service.js'; @@ -7,7 +13,9 @@ import { CPatientsService } from './c-patients.service.js'; * C 端患者控制器:家属跨院聚合查询。 */ @ApiTags('患者管理(C端)') +@ApiBearerAuth('bearer') @Controller('c/patients') +@UseGuards(AccessTokenGuard) export class CPatientsController { constructor(private readonly patientsService: CPatientsService) {} diff --git a/src/patients/c-patients/c-patients.service.ts b/src/patients/c-patients/c-patients.service.ts index a99db5d..79f64c7 100644 --- a/src/patients/c-patients/c-patients.service.ts +++ b/src/patients/c-patients/c-patients.service.ts @@ -1,4 +1,8 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '../../prisma.service.js'; import { MESSAGES } from '../../common/messages.js'; @@ -57,7 +61,6 @@ export class CPatientsService { patient: { id: this.toJsonNumber(patient.id), name: patient.name, - phone: patient.phone, }, device: { id: this.toJsonNumber(device.id), @@ -68,9 +71,6 @@ export class CPatientsService { task: { id: this.toJsonNumber(task.id), status: task.status, - creatorId: this.toJsonNumber(task.creatorId), - engineerId: this.toJsonNumber(task.engineerId), - hospitalId: this.toJsonNumber(task.hospitalId), createdAt: task.createdAt, }, taskItem: { @@ -89,8 +89,6 @@ export class CPatientsService { ); return { - phone, - idCardHash, patientCount: patients.length, lifecycle, }; diff --git a/src/tasks/task.service.ts b/src/tasks/task.service.ts index c7aa1b3..8027030 100644 --- a/src/tasks/task.service.ts +++ b/src/tasks/task.service.ts @@ -43,7 +43,9 @@ export class TaskService { throw new BadRequestException(`deviceId 非法: ${item.deviceId}`); } if (!Number.isInteger(item.targetPressure)) { - throw new BadRequestException(`targetPressure 非法: ${item.targetPressure}`); + throw new BadRequestException( + `targetPressure 非法: ${item.targetPressure}`, + ); } return item.deviceId; }), @@ -138,14 +140,30 @@ export class TaskService { throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED); } - const updatedTask = await this.prisma.task.update({ - where: { id: task.id }, + const accepted = await this.prisma.task.updateMany({ + where: { + id: task.id, + hospitalId, + status: TaskStatus.PENDING, + OR: [{ engineerId: null }, { engineerId: actor.id }], + }, data: { status: TaskStatus.ACCEPTED, engineerId: actor.id, }, + }); + + if (accepted.count !== 1) { + throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING); + } + + const updatedTask = await this.prisma.task.findUnique({ + where: { id: task.id }, include: { items: true }, }); + if (!updatedTask) { + throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND); + } await this.eventEmitter.emitAsync('task.accepted', { taskId: updatedTask.id, diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 834b5ac..ba9f0be 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -40,20 +40,18 @@ export class UsersController { @Post() @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @ApiOperation({ summary: '创建用户' }) - create(@Body() createUserDto: CreateUserDto) { - return this.usersService.create(createUserDto); + create( + @CurrentActor() actor: ActorContext, + @Body() createUserDto: CreateUserDto, + ) { + return this.usersService.create(actor, createUserDto); } /** * 查询用户列表。 */ @Get() - @Roles( - Role.SYSTEM_ADMIN, - Role.HOSPITAL_ADMIN, - Role.DIRECTOR, - Role.LEADER, - ) + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER) @ApiOperation({ summary: '查询用户列表' }) findAll(@CurrentActor() actor: ActorContext) { return this.usersService.findAll(actor); @@ -66,8 +64,8 @@ export class UsersController { @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @ApiOperation({ summary: '查询用户详情' }) @ApiParam({ name: 'id', description: '用户 ID' }) - findOne(@Param('id') id: string) { - return this.usersService.findOne(+id); + findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) { + return this.usersService.findOne(actor, +id); } /** @@ -77,8 +75,12 @@ export class UsersController { @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) @ApiOperation({ summary: '更新用户' }) @ApiParam({ name: 'id', description: '用户 ID' }) - update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { - return this.usersService.update(+id, updateUserDto); + update( + @CurrentActor() actor: ActorContext, + @Param('id') id: string, + @Body() updateUserDto: UpdateUserDto, + ) { + return this.usersService.update(actor, +id, updateUserDto); } /** @@ -88,7 +90,7 @@ export class UsersController { @Roles(Role.SYSTEM_ADMIN) @ApiOperation({ summary: '删除用户' }) @ApiParam({ name: 'id', description: '用户 ID' }) - remove(@Param('id') id: string) { - return this.usersService.remove(+id); + remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) { + return this.usersService.remove(actor, +id); } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index bf46a71..bea2dce 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -15,9 +15,9 @@ import { Role } from '../generated/prisma/enums.js'; import { PrismaService } from '../prisma.service.js'; import type { ActorContext } from '../common/actor-context.js'; import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js'; -import { RegisterUserDto } from './dto/register-user.dto.js'; import { LoginDto } from './dto/login.dto.js'; import { MESSAGES } from '../common/messages.js'; +import { CreateSystemAdminDto } from '../auth/dto/create-system-admin.dto.js'; const SAFE_USER_SELECT = { id: true, @@ -35,25 +35,27 @@ export class UsersService { constructor(private readonly prisma: PrismaService) {} /** - * 注册账号:根据角色与组织范围进行约束,并写入 bcrypt 密码摘要。 + * 注册接口已关闭,避免绕过管理员创建链路。 */ - async register(dto: RegisterUserDto) { - const role = this.normalizeRole(dto.role); + async register() { + throw new ForbiddenException(MESSAGES.AUTH.REGISTER_DISABLED); + } + + /** + * 创建系统管理员:要求引导密钥。 + */ + async createSystemAdmin(dto: CreateSystemAdminDto) { const name = this.normalizeRequiredString(dto.name, 'name'); const phone = this.normalizePhone(dto.phone); const password = this.normalizePassword(dto.password); const openId = this.normalizeOptionalString(dto.openId); - const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId'); - const departmentId = this.normalizeOptionalInt( - dto.departmentId, - 'departmentId', - ); - const groupId = this.normalizeOptionalInt(dto.groupId, 'groupId'); - this.assertSystemAdminBootstrapKey(role, dto.systemAdminBootstrapKey); - await this.assertOrganizationScope(role, hospitalId, departmentId, groupId); + this.assertSystemAdminBootstrapKey( + Role.SYSTEM_ADMIN, + dto.systemAdminBootstrapKey, + ); await this.assertOpenIdUnique(openId); - await this.assertPhoneRoleScopeUnique(phone, role, hospitalId); + await this.assertPhoneRoleScopeUnique(phone, Role.SYSTEM_ADMIN, null); const passwordHash = await hash(password, 12); @@ -63,10 +65,10 @@ export class UsersService { phone, passwordHash, openId, - role, - hospitalId, - departmentId, - groupId, + role: Role.SYSTEM_ADMIN, + hospitalId: null, + departmentId: null, + groupId: null, }, select: SAFE_USER_SELECT, }); @@ -133,13 +135,13 @@ export class UsersService { * 获取当前登录用户详情。 */ async me(actor: ActorContext) { - return this.findOne(actor.id); + return this.findOne(actor, actor.id); } /** * B 端创建用户(通常由管理员使用)。 */ - async create(createUserDto: CreateUserDto) { + async create(actor: ActorContext, createUserDto: CreateUserDto) { const role = this.normalizeRole(createUserDto.role); const name = this.normalizeRequiredString(createUserDto.name, 'name'); const phone = this.normalizePhone(createUserDto.phone); @@ -157,9 +159,22 @@ export class UsersService { ); const groupId = this.normalizeOptionalInt(createUserDto.groupId, 'groupId'); - await this.assertOrganizationScope(role, hospitalId, departmentId, groupId); + const scoped = this.resolveCreateScope( + actor, + role, + hospitalId, + departmentId, + groupId, + ); + + await this.assertOrganizationScope( + role, + scoped.hospitalId, + scoped.departmentId, + scoped.groupId, + ); await this.assertOpenIdUnique(openId); - await this.assertPhoneRoleScopeUnique(phone, role, hospitalId); + await this.assertPhoneRoleScopeUnique(phone, role, scoped.hospitalId); return this.prisma.user.create({ data: { @@ -168,9 +183,9 @@ export class UsersService { passwordHash: password ? await hash(password, 12) : null, openId, role, - hospitalId, - departmentId, - groupId, + hospitalId: scoped.hospitalId, + departmentId: scoped.departmentId, + groupId: scoped.groupId, }, select: SAFE_USER_SELECT, }); @@ -210,7 +225,7 @@ export class UsersService { /** * 查询用户详情。 */ - async findOne(id: number) { + async findOne(actor: ActorContext, id: number) { const userId = this.normalizeRequiredInt(id, 'id'); const user = await this.prisma.user.findUnique({ @@ -221,13 +236,15 @@ export class UsersService { throw new NotFoundException(MESSAGES.USER.NOT_FOUND); } + this.assertUserReadable(actor, user); + return user; } /** * 更新用户信息(含可选密码重置)。 */ - async update(id: number, updateUserDto: UpdateUserDto) { + async update(actor: ActorContext, id: number, updateUserDto: UpdateUserDto) { const userId = this.normalizeRequiredInt(id, 'id'); const current = await this.prisma.user.findUnique({ where: { id: userId }, @@ -240,8 +257,12 @@ export class UsersService { throw new NotFoundException(MESSAGES.USER.NOT_FOUND); } + this.assertUserWritable(actor, current); + const nextRole = - updateUserDto.role != null ? this.normalizeRole(updateUserDto.role) : current.role; + updateUserDto.role != null + ? this.normalizeRole(updateUserDto.role) + : current.role; const nextHospitalId = updateUserDto.hospitalId !== undefined ? this.normalizeOptionalInt(updateUserDto.hospitalId, 'hospitalId') @@ -255,6 +276,9 @@ export class UsersService { ? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId') : current.groupId; + this.assertUpdateTargetRoleAllowed(actor, nextRole); + this.assertUpdateHospitalScopeAllowed(actor, nextHospitalId); + const assigningDepartmentOrGroup = (updateUserDto.departmentId !== undefined && nextDepartmentId != null) || (updateUserDto.groupId !== undefined && nextGroupId != null); @@ -329,9 +353,10 @@ export class UsersService { /** * 删除用户。 */ - async remove(id: number) { + async remove(actor: ActorContext, id: number) { const userId = this.normalizeRequiredInt(id, 'id'); - await this.findOne(userId); + const target = await this.findOne(actor, userId); + this.assertUserWritable(actor, target); try { return await this.prisma.user.delete({ @@ -397,7 +422,9 @@ export class UsersService { /** * 去除密码摘要,避免泄露敏感信息。 */ - private toSafeUser(user: { passwordHash?: string | null } & Record) { + private toSafeUser( + user: { passwordHash?: string | null } & Record, + ) { const { passwordHash, ...safe } = user; return safe; } @@ -512,7 +539,9 @@ export class UsersService { select: { id: true, hospitalId: true }, }); if (!department || department.hospitalId !== hospitalId) { - throw new BadRequestException(MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH); + throw new BadRequestException( + MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH, + ); } } @@ -630,6 +659,163 @@ export class UsersService { return role as Role; } + /** + * 解析创建用户作用域并执行角色链路约束。 + */ + private resolveCreateScope( + actor: ActorContext, + targetRole: Role, + hospitalId: number | null, + departmentId: number | null, + groupId: number | null, + ) { + if (actor.role === Role.SYSTEM_ADMIN) { + if (targetRole === Role.SYSTEM_ADMIN) { + return { hospitalId: null, departmentId: null, groupId: null }; + } + + // 系统管理员可创建任意角色,具体归属由后续组织范围校验保证合法。 + return { hospitalId, departmentId, groupId }; + } + + if (actor.role !== Role.HOSPITAL_ADMIN) { + throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN); + } + + if ( + targetRole === Role.SYSTEM_ADMIN || + targetRole === Role.HOSPITAL_ADMIN + ) { + throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN); + } + + const actorHospitalId = this.requireActorScopeInt( + actor.hospitalId, + MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, + ); + const scopedHospitalId = hospitalId ?? actorHospitalId; + if (scopedHospitalId !== actorHospitalId) { + throw new ForbiddenException( + MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN, + ); + } + + return { + hospitalId: scopedHospitalId, + departmentId, + groupId, + }; + } + + /** + * 读取权限:系统管理员可读全量;其余仅可读自己与本院(医院管理员)。 + */ + private assertUserReadable( + actor: ActorContext, + target: Pick & { + id: number; + role: Role; + hospitalId: number | null; + }, + ) { + if (actor.role === Role.SYSTEM_ADMIN) { + return; + } + if (actor.id === target.id) { + return; + } + + if (actor.role === Role.HOSPITAL_ADMIN) { + const actorHospitalId = this.requireActorScopeInt( + actor.hospitalId, + MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, + ); + if (target.hospitalId === actorHospitalId) { + return; + } + } + + throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); + } + + /** + * 写权限:医院管理员仅可写本院非管理员账号。 + */ + private assertUserWritable( + actor: ActorContext, + target: { + id: number; + role: Role; + hospitalId: number | null; + }, + ) { + if (actor.role === Role.SYSTEM_ADMIN) { + return; + } + + if (actor.role !== Role.HOSPITAL_ADMIN) { + throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); + } + + const actorHospitalId = this.requireActorScopeInt( + actor.hospitalId, + MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, + ); + if (target.hospitalId !== actorHospitalId) { + throw new ForbiddenException( + MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN, + ); + } + if ( + target.role === Role.HOSPITAL_ADMIN || + target.role === Role.SYSTEM_ADMIN + ) { + throw new ForbiddenException( + MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN, + ); + } + } + + /** + * 写入时角色边界校验,避免医院管理员提升权限。 + */ + private assertUpdateTargetRoleAllowed(actor: ActorContext, nextRole: Role) { + if (actor.role === Role.SYSTEM_ADMIN) { + return; + } + + if ( + actor.role === Role.HOSPITAL_ADMIN && + (nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN) + ) { + throw new ForbiddenException( + MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN, + ); + } + } + + /** + * 写入时医院范围校验,避免医院管理员跨院更新。 + */ + private assertUpdateHospitalScopeAllowed( + actor: ActorContext, + hospitalId: number | null, + ) { + if (actor.role !== Role.HOSPITAL_ADMIN) { + return; + } + + const actorHospitalId = this.requireActorScopeInt( + actor.hospitalId, + MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED, + ); + if (hospitalId !== actorHospitalId) { + throw new ForbiddenException( + MESSAGES.USER.HOSPITAL_ADMIN_SCOPE_FORBIDDEN, + ); + } + } + /** * 签发访问令牌。 */ diff --git a/tyt-admin/components.d.ts b/tyt-admin/components.d.ts index d728200..5806b1b 100644 --- a/tyt-admin/components.d.ts +++ b/tyt-admin/components.d.ts @@ -45,6 +45,7 @@ declare module 'vue' { ElTimeline: typeof import('element-plus/es')['ElTimeline'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTree: typeof import('element-plus/es')['ElTree'] + ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/tyt-admin/src/views/Login.vue b/tyt-admin/src/views/Login.vue index cda40ab..1f76d26 100644 --- a/tyt-admin/src/views/Login.vue +++ b/tyt-admin/src/views/Login.vue @@ -16,7 +16,7 @@ - + diff --git a/tyt-admin/src/views/organization/Departments.vue b/tyt-admin/src/views/organization/Departments.vue index 31b8d61..07e49b4 100644 --- a/tyt-admin/src/views/organization/Departments.vue +++ b/tyt-admin/src/views/organization/Departments.vue @@ -3,16 +3,35 @@ - +
- - + + - + - 查询 + 查询 重置 - 新增科室 + 新增科室
- + - +