fix permissions and teacher attendance workflows

This commit is contained in:
2026-07-10 20:40:52 +08:00
parent 247879f276
commit 8ed1682b90
95 changed files with 4745 additions and 1237 deletions

View 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']),
);
});
});

View 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']),
);
});
});

View File

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