fix permissions and teacher attendance workflows
This commit is contained in:
23
apps/server/src/rbac/rbac.permissions.spec.ts
Normal file
23
apps/server/src/rbac/rbac.permissions.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { PRESET_ROLES } from './rbac.service';
|
||||
|
||||
describe('preset role permissions', () => {
|
||||
it('gives teachers explicit workspace permissions without class/schedule delete privileges', () => {
|
||||
const teacher = PRESET_ROLES.find((role) => role.code === 'teacher');
|
||||
|
||||
expect(teacher).toBeDefined();
|
||||
expect(teacher?.permissionGroups).toEqual(['notification', 'profile']);
|
||||
expect(teacher?.extraPermissions).toEqual(
|
||||
expect.arrayContaining([
|
||||
'student:view',
|
||||
'class:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'attendance:create',
|
||||
'attendance:export',
|
||||
]),
|
||||
);
|
||||
expect(teacher?.extraPermissions).not.toEqual(
|
||||
expect.arrayContaining(['class:delete', 'schedule:delete']),
|
||||
);
|
||||
});
|
||||
});
|
||||
57
apps/server/src/rbac/rbac.seed.spec.ts
Normal file
57
apps/server/src/rbac/rbac.seed.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { RbacService } from './rbac.service';
|
||||
|
||||
describe('RbacService seedData', () => {
|
||||
it('adds preset permissions to system roles without removing manually granted permissions', async () => {
|
||||
const permissions = [
|
||||
{ id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' },
|
||||
{ id: 2, code: 'notification:view', name: '查看通知', group: 'notification' },
|
||||
{ id: 3, code: 'student:view', name: '查看学生', group: 'student' },
|
||||
{ id: 4, code: 'class:view', name: '查看班级', group: 'class' },
|
||||
{ id: 5, code: 'schedule:view', name: '查看排课', group: 'schedule' },
|
||||
{ id: 6, code: 'attendance:view', name: '查看考勤', group: 'attendance' },
|
||||
{ id: 7, code: 'attendance:create', name: '新增考勤', group: 'attendance' },
|
||||
{ id: 8, code: 'attendance:export', name: '导出考勤', group: 'attendance' },
|
||||
{ id: 9, code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||
];
|
||||
const teacherRole = {
|
||||
id: 1,
|
||||
name: '老师',
|
||||
description: '查看和管理本班学生',
|
||||
isSystem: true,
|
||||
permissions: [permissions[8]],
|
||||
};
|
||||
|
||||
const permRepo = {
|
||||
findOne: jest.fn(
|
||||
async ({ where }: any) => permissions.find((p) => p.code === where.code) ?? null,
|
||||
),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => permissions),
|
||||
};
|
||||
const roleRepo = {
|
||||
findOne: jest.fn(async ({ where }: any) => (where.name === '老师' ? teacherRole : null)),
|
||||
create: jest.fn((value) => ({ ...value, permissions: [] })),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => [teacherRole]),
|
||||
};
|
||||
const userRepo = { count: jest.fn(async () => 1), create: jest.fn(), save: jest.fn() };
|
||||
|
||||
const service = new RbacService(
|
||||
permRepo as never,
|
||||
roleRepo as never,
|
||||
userRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.seedData();
|
||||
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).toEqual(
|
||||
expect.arrayContaining(['room:view', 'profile:view', 'student:view', 'attendance:create']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,21 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student } from '../entities';
|
||||
import {
|
||||
Permission,
|
||||
Role,
|
||||
User,
|
||||
Class,
|
||||
ClassStudent,
|
||||
ClassTeacher,
|
||||
ClassSchedule,
|
||||
Student,
|
||||
} from '../entities';
|
||||
|
||||
const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [
|
||||
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
|
||||
{ code: 'profile:view', name: '查看个人资料', group: 'profile' },
|
||||
{ code: 'notification:view', name: '查看通知', group: 'notification' },
|
||||
{ code: 'student:view', name: '查看学生', group: 'student' },
|
||||
{ code: 'student:create', name: '新增学生', group: 'student' },
|
||||
{ code: 'student:edit', name: '编辑学生', group: 'student' },
|
||||
@@ -87,7 +98,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'department:delete', name: '删除部门', group: 'department' },
|
||||
];
|
||||
|
||||
const PRESET_ROLES: Array<{
|
||||
export const PRESET_ROLES: Array<{
|
||||
name: string;
|
||||
code: string;
|
||||
description: string;
|
||||
@@ -119,6 +130,8 @@ const PRESET_ROLES: Array<{
|
||||
'class',
|
||||
'schedule',
|
||||
'attendance',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -126,36 +139,61 @@ const PRESET_ROLES: Array<{
|
||||
code: 'teacher',
|
||||
description: '查看和管理本班学生',
|
||||
isSystem: true,
|
||||
permissionGroups: ['student'],
|
||||
extraPermissions: ['student:view'],
|
||||
permissionGroups: ['notification', 'profile'],
|
||||
extraPermissions: [
|
||||
'student:view',
|
||||
'class:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'attendance:create',
|
||||
'attendance:export',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '机构负责人',
|
||||
code: 'institution_head',
|
||||
description: '管理机构教室和课程',
|
||||
isSystem: true,
|
||||
permissionGroups: ['classroom', 'rental', 'tenant'],
|
||||
permissionGroups: ['classroom', 'rental', 'tenant', 'notification', 'profile'],
|
||||
},
|
||||
{
|
||||
name: '财务',
|
||||
code: 'finance',
|
||||
description: '管理费用、账单与押金',
|
||||
isSystem: true,
|
||||
permissionGroups: ['expense', 'bill', 'deposit', 'dashboard'],
|
||||
permissionGroups: ['expense', 'bill', 'deposit', 'dashboard', 'notification', 'profile'],
|
||||
},
|
||||
{
|
||||
name: '宿管',
|
||||
code: 'dorm_manager',
|
||||
description: '管理宿舍入住与宿舍信息',
|
||||
isSystem: true,
|
||||
permissionGroups: ['student', 'room', 'occupancy', 'deposit', 'dashboard'],
|
||||
permissionGroups: [
|
||||
'student',
|
||||
'room',
|
||||
'occupancy',
|
||||
'deposit',
|
||||
'dashboard',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '教务',
|
||||
code: 'academic',
|
||||
description: '管理班级、排课、考勤、学习与考试',
|
||||
isSystem: true,
|
||||
permissionGroups: ['class', 'schedule', 'attendance', 'classroom', 'learning', 'exam', 'dashboard'],
|
||||
permissionGroups: [
|
||||
'class',
|
||||
'schedule',
|
||||
'attendance',
|
||||
'classroom',
|
||||
'learning',
|
||||
'exam',
|
||||
'dashboard',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -189,7 +227,12 @@ export class RbacService {
|
||||
const exists = await this.roleRepo.findOne({ where: { name: r.name } });
|
||||
if (!exists) {
|
||||
await this.roleRepo.save(
|
||||
this.roleRepo.create({ name: r.name, description: r.description, isSystem: r.isSystem }),
|
||||
this.roleRepo.create({
|
||||
name: r.name,
|
||||
code: r.code,
|
||||
description: r.description,
|
||||
isSystem: r.isSystem,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -197,8 +240,12 @@ export class RbacService {
|
||||
|
||||
// Step 3: 构建角色-权限关联
|
||||
for (const preset of PRESET_ROLES) {
|
||||
const role = allRoles.find((r) => r.name === preset.name);
|
||||
const role = allRoles.find((r) => r.name === preset.name || r.code === preset.code);
|
||||
if (!role) continue;
|
||||
if (role.code !== preset.code) {
|
||||
role.code = preset.code;
|
||||
await this.roleRepo.save(role);
|
||||
}
|
||||
|
||||
let perms: Permission[];
|
||||
if (preset.permissionGroups.length === 0) {
|
||||
@@ -215,11 +262,12 @@ export class RbacService {
|
||||
);
|
||||
}
|
||||
|
||||
// 幂等:只插入尚未关联的
|
||||
const existingIds = new Set(role.permissions.map((p) => p.id));
|
||||
const toAdd = perms.filter((p) => !existingIds.has(p.id));
|
||||
if (toAdd.length > 0) {
|
||||
role.permissions = [...role.permissions, ...toAdd];
|
||||
// 系统角色只补齐预置权限,不移除管理员手动授予的额外权限。
|
||||
// 这样新增权限(例如 profile:view)会自动补上,同时避免重启后覆盖人工配置。
|
||||
const currentIds = new Set(role.permissions.map((permission) => permission.id));
|
||||
const missingPerms = perms.filter((permission) => !currentIds.has(permission.id));
|
||||
if (missingPerms.length > 0) {
|
||||
role.permissions = [...role.permissions, ...missingPerms];
|
||||
await this.roleRepo.save(role);
|
||||
}
|
||||
}
|
||||
@@ -247,7 +295,6 @@ export class RbacService {
|
||||
this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`);
|
||||
}
|
||||
|
||||
|
||||
async findAllRoles(): Promise<Role[]> {
|
||||
return this.roleRepo.find({
|
||||
relations: ['permissions'],
|
||||
@@ -450,14 +497,18 @@ export class RbacService {
|
||||
};
|
||||
}
|
||||
|
||||
async updateUserProfile(id: number, dto: { subjects?: string[]; joinedAt?: string; qualifications?: string }) {
|
||||
async updateUserProfile(
|
||||
id: number,
|
||||
dto: { subjects?: string[]; joinedAt?: string; qualifications?: string },
|
||||
) {
|
||||
const user = await this.userRepo.findOne({ where: { id } });
|
||||
if (!user) throw new Error('用户不存在');
|
||||
const current = user.profile || {};
|
||||
user.profile = {
|
||||
subjects: dto.subjects !== undefined ? dto.subjects : current.subjects,
|
||||
joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt,
|
||||
qualifications: dto.qualifications !== undefined ? dto.qualifications : current.qualifications,
|
||||
qualifications:
|
||||
dto.qualifications !== undefined ? dto.qualifications : current.qualifications,
|
||||
};
|
||||
await this.userRepo.save(user);
|
||||
return { message: '资料已更新', profile: user.profile };
|
||||
@@ -501,6 +552,7 @@ export class RbacService {
|
||||
.andWhere('cs.startDate <= :today', { today: todayStr })
|
||||
.andWhere('cs.endDate >= :today', { today: todayStr })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.teacherId = :userId', { userId })
|
||||
.orderBy('cs.startTime', 'ASC')
|
||||
.getMany();
|
||||
|
||||
@@ -524,6 +576,8 @@ export class RbacService {
|
||||
todaySchedules: todaySchedules.map((s) => ({
|
||||
id: s.id,
|
||||
classId: s.classId,
|
||||
classroomId: s.classroomId,
|
||||
teacherId: s.teacherId,
|
||||
weekDay: s.weekDay,
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
@@ -535,18 +589,18 @@ export class RbacService {
|
||||
}
|
||||
|
||||
async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) {
|
||||
const page = query?.page || 1;
|
||||
const pageSize = query?.pageSize || 20;
|
||||
const teacherRoleCodes = ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'];
|
||||
const teacherRoleNames = ['老师', '班主任', '宿管老师', '超管'];
|
||||
|
||||
const qb = this.userRepo
|
||||
.createQueryBuilder('u')
|
||||
.leftJoin('u.roles', 'role')
|
||||
.leftJoin(ClassTeacher, 'ct', 'ct.userId = u.id')
|
||||
.leftJoin('ct.class', 'c')
|
||||
.select([
|
||||
'u.id', 'u.username', 'u.name', 'u.isActive', 'u.profile', 'u.lastLoginAt',
|
||||
'role.code', 'role.name',
|
||||
'ct.roleType', 'ct.subject', 'ct.id',
|
||||
'c.id', 'c.name',
|
||||
])
|
||||
.where('role.code IN (:...roles)', { roles: ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'] });
|
||||
.leftJoinAndSelect('u.roles', 'role')
|
||||
.where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', {
|
||||
roleCodes: teacherRoleCodes,
|
||||
roleNames: teacherRoleNames,
|
||||
});
|
||||
|
||||
if (query?.search) {
|
||||
qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` });
|
||||
@@ -555,37 +609,38 @@ export class RbacService {
|
||||
const total = await qb.getCount();
|
||||
const users = await qb
|
||||
.orderBy('u.name', 'ASC')
|
||||
.skip(((query?.page || 1) - 1) * (query?.pageSize || 20))
|
||||
.take(query?.pageSize || 20)
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getMany();
|
||||
|
||||
const list = users.map((u) => {
|
||||
// TypeORM injects __ct__ and __class__ via leftJoin on non-entity relations
|
||||
const raw = u as unknown as Record<string, unknown>;
|
||||
const classAssignments: Array<{ roleType?: string; subject?: string; className: string | null }> = [];
|
||||
const rawCt = raw['__ct__'];
|
||||
if (Array.isArray(rawCt)) {
|
||||
for (const ct of rawCt) {
|
||||
const ctRaw = ct as Record<string, unknown>;
|
||||
const cls = ctRaw['__class__'] as Record<string, unknown> | undefined;
|
||||
classAssignments.push({
|
||||
roleType: typeof ctRaw['roleType'] === 'string' ? ctRaw['roleType'] : undefined,
|
||||
subject: typeof ctRaw['subject'] === 'string' ? ctRaw['subject'] : undefined,
|
||||
className: cls && typeof cls['name'] === 'string' ? cls['name'] : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
isActive: u.isActive,
|
||||
profile: u.profile,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
roles: u.roles || [],
|
||||
classAssignments,
|
||||
};
|
||||
});
|
||||
const userIds = users.map((user) => user.id);
|
||||
const assignments =
|
||||
userIds.length > 0
|
||||
? await this.classTeacherRepo.find({ where: { userId: In(userIds) }, relations: ['class'] })
|
||||
: [];
|
||||
const assignmentsByUser = new Map<number, ClassTeacher[]>();
|
||||
for (const assignment of assignments) {
|
||||
const list = assignmentsByUser.get(assignment.userId) || [];
|
||||
list.push(assignment);
|
||||
assignmentsByUser.set(assignment.userId, list);
|
||||
}
|
||||
|
||||
const list = users.map((u) => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
isActive: u.isActive,
|
||||
profile: u.profile,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
roles: u.roles || [],
|
||||
classAssignments: (assignmentsByUser.get(u.id) || []).map((assignment) => ({
|
||||
id: assignment.id,
|
||||
classId: assignment.classId,
|
||||
roleType: assignment.roleType,
|
||||
subject: assignment.subject,
|
||||
className: assignment.class?.name || null,
|
||||
})),
|
||||
}));
|
||||
|
||||
return { list, total };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user