fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
@@ -126,7 +126,7 @@ export class RbacController {
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
@Get('users')
|
||||
@RequirePermission('user:view')
|
||||
@RequirePermission('user:view', 'teacher:view')
|
||||
getUsers(@Query('isArchived') isArchived?: string) {
|
||||
const archived = isArchived === 'true';
|
||||
return this.rbacService.findAllUsers(archived);
|
||||
@@ -301,7 +301,7 @@ export class RbacController {
|
||||
// ---- 教师工作台 ----
|
||||
|
||||
@Get('teacher-workspace')
|
||||
@RequirePermission('class:view')
|
||||
@RequirePermission('teacher-workspace:view')
|
||||
async getTeacherWorkspace(@Request() req: any) {
|
||||
return this.rbacService.getTeacherWorkspace(req.user?.id);
|
||||
}
|
||||
@@ -309,7 +309,7 @@ export class RbacController {
|
||||
// ---- 教师管理 ----
|
||||
|
||||
@Get('teachers')
|
||||
@RequirePermission('user:view')
|
||||
@RequirePermission('teacher:view')
|
||||
async getTeachers(
|
||||
@Query('search') search?: string,
|
||||
@Query('page') page?: string,
|
||||
@@ -323,7 +323,7 @@ export class RbacController {
|
||||
}
|
||||
|
||||
@Put('teachers/:id/profile')
|
||||
@RequirePermission('user:edit')
|
||||
@RequirePermission('teacher:edit')
|
||||
async updateTeacherProfile(
|
||||
@Param('id') id: string,
|
||||
@Body() profile: UpdateProfileDto,
|
||||
|
||||
@@ -7,32 +7,59 @@ function permissionsFor(roleCode: string): { groups: string[]; extras: string[]
|
||||
}
|
||||
|
||||
describe('preset role permissions', () => {
|
||||
it('gives teachers explicit workspace permissions without class/schedule delete privileges', () => {
|
||||
it('keeps teachers read-only in scheduling while preserving class attendance access', () => {
|
||||
const teacher = permissionsFor('teacher');
|
||||
|
||||
expect(teacher.groups).toEqual(['notification', 'profile']);
|
||||
expect(teacher.extras).toEqual(
|
||||
expect.arrayContaining([
|
||||
'student:view',
|
||||
'class:view',
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'attendance:create',
|
||||
'attendance:export',
|
||||
'attendance:self-edit',
|
||||
]),
|
||||
);
|
||||
expect(teacher.extras).not.toEqual(
|
||||
expect.arrayContaining(['class:delete', 'schedule:delete']),
|
||||
expect(teacher.extras).not.toContain('schedule:create');
|
||||
expect(teacher.extras).not.toContain('schedule:edit');
|
||||
expect(teacher.extras).not.toContain('schedule:delete');
|
||||
expect(teacher.extras).not.toContain('student:view');
|
||||
expect(teacher.extras).not.toContain('class:view');
|
||||
expect(teacher.extras).not.toContain('attendance:export');
|
||||
});
|
||||
|
||||
|
||||
it('gives academic administrators the complete teaching administration workflow', () => {
|
||||
const academic = permissionsFor('academic');
|
||||
expect(academic.groups).toEqual(
|
||||
expect.arrayContaining(['student', 'class', 'schedule', 'attendance', 'classroom']),
|
||||
);
|
||||
expect(academic.extras).toEqual(expect.arrayContaining(['sync:read', 'sync:trigger']));
|
||||
});
|
||||
|
||||
it('combines accommodation, expenses, bills and deposits in one operations role', () => {
|
||||
const accommodation = permissionsFor('accommodation_operations');
|
||||
expect(accommodation.groups).toEqual(
|
||||
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
|
||||
);
|
||||
expect(accommodation.extras).toContain('student:basic-view');
|
||||
});
|
||||
|
||||
it('keeps classroom rental operations separate from accommodation operations', () => {
|
||||
const classroomOperations = permissionsFor('classroom_operations');
|
||||
expect(classroomOperations.groups).toEqual(
|
||||
expect.arrayContaining(['classroom', 'rental', 'organization']),
|
||||
);
|
||||
expect(classroomOperations.groups).not.toEqual(expect.arrayContaining(['room', 'deposit']));
|
||||
});
|
||||
|
||||
it('limits system administrators to accounts, permissions, logs and integrations', () => {
|
||||
const systemAdmin = permissionsFor('system_admin');
|
||||
expect(systemAdmin.groups).toEqual(
|
||||
expect.arrayContaining(['user', 'role', 'log', 'integration', 'sync', 'ai']),
|
||||
);
|
||||
expect(systemAdmin.groups).not.toEqual(
|
||||
expect.arrayContaining(['student', 'schedule', 'attendance', 'expense']),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives institution heads every read permission required by the classroom rental pages', () => {
|
||||
const role = permissionsFor('institution_head');
|
||||
expect(role.groups).toEqual(expect.arrayContaining(['classroom', 'rental', 'organization']));
|
||||
});
|
||||
|
||||
it('keeps roles without dashboard access off the dashboard', () => {
|
||||
expect(permissionsFor('teacher').groups).not.toContain('dashboard');
|
||||
expect(permissionsFor('institution_head').groups).not.toContain('dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RbacService } from './rbac.service';
|
||||
|
||||
describe('RbacService seedData', () => {
|
||||
it('adds preset permissions to system roles without removing manually granted permissions', async () => {
|
||||
it('migrates the legacy teacher role and replaces broad permissions with the teaching matrix', async () => {
|
||||
const permissions = [
|
||||
{ id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' },
|
||||
{ id: 2, code: 'notification:view', name: '查看通知', group: 'notification' },
|
||||
@@ -10,27 +10,32 @@ describe('RbacService seedData', () => {
|
||||
{ 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: 8, code: 'teacher-workspace:view', name: '教师工作台', group: 'teacher-workspace' },
|
||||
{ id: 9, code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||
{ id: 10, code: 'schedule:create', name: '新增排课', group: 'schedule' },
|
||||
];
|
||||
const teacherRole = {
|
||||
id: 1,
|
||||
name: '老师',
|
||||
description: '查看和管理本班学生',
|
||||
code: 'teacher',
|
||||
description: '旧角色',
|
||||
isSystem: true,
|
||||
permissions: [permissions[8]],
|
||||
status: 1,
|
||||
permissions: [permissions[2], permissions[3], permissions[8]],
|
||||
};
|
||||
|
||||
const permRepo = {
|
||||
findOne: jest.fn(
|
||||
async ({ where }: any) => permissions.find((p) => p.code === where.code) ?? null,
|
||||
async ({ where }: any) => permissions.find((permission) => permission.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)),
|
||||
findOne: jest.fn(async ({ where }: any) =>
|
||||
where.code === 'teacher' || where.name === '老师' ? teacherRole : null,
|
||||
),
|
||||
create: jest.fn((value) => ({ ...value, permissions: [] })),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => [teacherRole]),
|
||||
@@ -50,8 +55,86 @@ describe('RbacService seedData', () => {
|
||||
|
||||
await service.seedData();
|
||||
|
||||
expect(teacherRole.name).toBe('任课老师');
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).toEqual(
|
||||
expect.arrayContaining(['room:view', 'profile:view', 'student:view', 'attendance:create']),
|
||||
expect.arrayContaining([
|
||||
'profile:view',
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:create',
|
||||
]),
|
||||
);
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain(
|
||||
'schedule:create',
|
||||
);
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('student:view');
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('class:view');
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('room:view');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RbacService legacy role consolidation', () => {
|
||||
it('moves users from duplicate accommodation roles before deleting the duplicates', async () => {
|
||||
const permissions = [
|
||||
{ id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' },
|
||||
{ id: 2, code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||
{ id: 3, code: 'expense:view', name: '查看费用', group: 'expense' },
|
||||
{ id: 4, code: 'student:basic-view', name: '学生基础信息', group: 'student-scope' },
|
||||
];
|
||||
const targetRole: any = {
|
||||
id: 10,
|
||||
name: '住宿运营管理员',
|
||||
code: 'accommodation_operations',
|
||||
description: '',
|
||||
isSystem: true,
|
||||
status: 1,
|
||||
permissions: [],
|
||||
users: [],
|
||||
};
|
||||
const legacyRole: any = {
|
||||
id: 11,
|
||||
name: '财务',
|
||||
code: 'finance',
|
||||
description: '',
|
||||
isSystem: true,
|
||||
status: 1,
|
||||
permissions: [],
|
||||
users: [{ id: 21 }],
|
||||
};
|
||||
const user: any = { id: 21, roles: [legacyRole] };
|
||||
const permRepo = {
|
||||
findOne: jest.fn(async ({ where }: any) => permissions.find((item) => item.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 () => targetRole),
|
||||
create: jest.fn((value) => ({ ...value, permissions: [] })),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => [targetRole, legacyRole]),
|
||||
remove: jest.fn(async (value) => value),
|
||||
};
|
||||
const userRepo = {
|
||||
count: jest.fn(async () => 1),
|
||||
findOne: jest.fn(async () => user),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
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(user.roles).toEqual([targetRole]);
|
||||
expect(userRepo.save).toHaveBeenCalledWith(user);
|
||||
expect(roleRepo.remove).toHaveBeenCalledWith(legacyRole);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,11 @@ 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:view', name: '查看学生管理', group: 'student' },
|
||||
{ code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' },
|
||||
{ code: 'teacher-workspace:view', name: '查看教师工作台', group: 'teacher-workspace' },
|
||||
{ code: 'teacher:view', name: '查看教师', group: 'teacher' },
|
||||
{ code: 'teacher:edit', name: '编辑教师', group: 'teacher' },
|
||||
{ code: 'student:create', name: '新增学生', group: 'student' },
|
||||
{ code: 'student:edit', name: '编辑学生', group: 'student' },
|
||||
{ code: 'student:delete', name: '删除学生', group: 'student' },
|
||||
@@ -46,7 +50,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'deposit:create', name: '新增押金', group: 'deposit' },
|
||||
{ code: 'deposit:edit', name: '编辑押金', group: 'deposit' },
|
||||
{ code: 'deposit:delete', name: '删除押金', group: 'deposit' },
|
||||
{ code: 'deposit:approve', name: '审批退款', group: 'deposit' },
|
||||
{ code: 'deposit:refund', name: '直接退还押金', group: 'deposit' },
|
||||
{ code: 'classroom:view', name: '查看教室', group: 'classroom' },
|
||||
{ code: 'classroom:create', name: '新增教室', group: 'classroom' },
|
||||
{ code: 'classroom:edit', name: '编辑教室', group: 'classroom' },
|
||||
@@ -80,7 +84,8 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'schedule:delete', name: '删除排课', group: 'schedule' },
|
||||
{ code: 'attendance:view', name: '查看考勤', group: 'attendance' },
|
||||
{ code: 'attendance:create', name: '新增考勤', group: 'attendance' },
|
||||
{ code: 'attendance:edit', name: '编辑考勤', group: 'attendance' },
|
||||
{ code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' },
|
||||
{ code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' },
|
||||
{ code: 'attendance:export', name: '导出考勤', group: 'attendance' },
|
||||
{ code: 'attendance:generate', name: '按课表生成考勤', group: 'attendance' },
|
||||
{ code: 'learning:create', name: '创建学习任务', group: 'learning' },
|
||||
@@ -108,85 +113,39 @@ export const PRESET_ROLES: Array<{
|
||||
isSystem: boolean;
|
||||
permissionGroups: string[];
|
||||
extraPermissions?: string[];
|
||||
legacyNames?: string[];
|
||||
legacyCodes?: string[];
|
||||
}> = [
|
||||
{
|
||||
name: '超管',
|
||||
name: '超级管理员',
|
||||
code: 'super_admin',
|
||||
description: '系统超级管理员,拥有全部权限',
|
||||
description: '系统初始化、应急维护和全局权限处理',
|
||||
isSystem: true,
|
||||
permissionGroups: [],
|
||||
legacyNames: ['超管', 'super_admin'],
|
||||
},
|
||||
{
|
||||
name: '宿管老师',
|
||||
code: 'dormitory_supervisor',
|
||||
description: '管理宿舍相关业务',
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'student',
|
||||
'room',
|
||||
'occupancy',
|
||||
'expense',
|
||||
'bill',
|
||||
'deposit',
|
||||
'log',
|
||||
'dashboard',
|
||||
'class',
|
||||
'schedule',
|
||||
'attendance',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '老师',
|
||||
name: '任课老师',
|
||||
code: 'teacher',
|
||||
description: '查看和管理本班学生',
|
||||
description: '查看自己的排课、今日课程和任教班级考勤',
|
||||
isSystem: true,
|
||||
permissionGroups: ['notification', 'profile'],
|
||||
extraPermissions: [
|
||||
'student:view',
|
||||
'class:view',
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'attendance:create',
|
||||
'attendance:export',
|
||||
'attendance:self-edit',
|
||||
],
|
||||
legacyNames: ['老师'],
|
||||
},
|
||||
{
|
||||
name: '机构负责人',
|
||||
code: 'institution_head',
|
||||
description: '管理机构教室和课程',
|
||||
isSystem: true,
|
||||
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'],
|
||||
},
|
||||
{
|
||||
name: '财务',
|
||||
code: 'finance',
|
||||
description: '管理费用、账单与押金',
|
||||
isSystem: true,
|
||||
permissionGroups: ['expense', 'bill', 'deposit', 'dashboard', 'notification', 'profile'],
|
||||
},
|
||||
{
|
||||
name: '宿管',
|
||||
code: 'dorm_manager',
|
||||
description: '管理宿舍入住与宿舍信息',
|
||||
name: '教务管理员',
|
||||
code: 'academic',
|
||||
description: '管理学生、班级、教师、全局排课和历史考勤',
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'student',
|
||||
'room',
|
||||
'occupancy',
|
||||
'deposit',
|
||||
'dashboard',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '教务',
|
||||
code: 'academic',
|
||||
description: '管理班级、排课、考勤、学习与考试',
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'class',
|
||||
'schedule',
|
||||
'attendance',
|
||||
@@ -197,6 +156,59 @@ export const PRESET_ROLES: Array<{
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
extraPermissions: [
|
||||
'teacher-workspace:view',
|
||||
'teacher:view',
|
||||
'teacher:edit',
|
||||
'sync:read',
|
||||
'sync:trigger',
|
||||
],
|
||||
legacyNames: ['教务'],
|
||||
},
|
||||
{
|
||||
name: '住宿运营管理员',
|
||||
code: 'accommodation_operations',
|
||||
description: '管理宿舍、入住、住宿费用、账单、押金和退宿结算',
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'room',
|
||||
'occupancy',
|
||||
'expense',
|
||||
'bill',
|
||||
'deposit',
|
||||
'dashboard',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
extraPermissions: ['student:basic-view'],
|
||||
legacyNames: ['宿管老师', '宿管', '财务'],
|
||||
legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'],
|
||||
},
|
||||
{
|
||||
name: '教室运营管理员',
|
||||
code: 'classroom_operations',
|
||||
description: '管理教室、教室排期、外部机构和租赁订单',
|
||||
isSystem: true,
|
||||
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'],
|
||||
legacyNames: ['机构负责人'],
|
||||
legacyCodes: ['institution_head'],
|
||||
},
|
||||
{
|
||||
name: '系统管理员',
|
||||
code: 'system_admin',
|
||||
description: '管理账号、角色、日志、同步和系统配置',
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'user',
|
||||
'role',
|
||||
'log',
|
||||
'integration',
|
||||
'sync',
|
||||
'ai',
|
||||
'department',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -215,6 +227,18 @@ export class RbacService {
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
) {}
|
||||
|
||||
private async findLegacyPresetRole(preset: (typeof PRESET_ROLES)[number]): Promise<Role | null> {
|
||||
for (const code of preset.legacyCodes ?? []) {
|
||||
const role = await this.roleRepo.findOne({ where: { code } });
|
||||
if (role) return role;
|
||||
}
|
||||
for (const name of preset.legacyNames ?? []) {
|
||||
const role = await this.roleRepo.findOne({ where: { name } });
|
||||
if (role) return role;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async seedData(): Promise<void> {
|
||||
// Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL)
|
||||
for (const p of PRESET_PERMISSIONS) {
|
||||
@@ -227,7 +251,10 @@ export class RbacService {
|
||||
|
||||
// Step 2: 幂等插入预置角色
|
||||
for (const r of PRESET_ROLES) {
|
||||
const exists = await this.roleRepo.findOne({ where: { name: r.name } });
|
||||
const exists =
|
||||
(await this.roleRepo.findOne({ where: { code: r.code } })) ||
|
||||
(await this.roleRepo.findOne({ where: { name: r.name } })) ||
|
||||
(await this.findLegacyPresetRole(r));
|
||||
if (!exists) {
|
||||
await this.roleRepo.save(
|
||||
this.roleRepo.create({
|
||||
@@ -239,14 +266,44 @@ export class RbacService {
|
||||
);
|
||||
}
|
||||
}
|
||||
const allRoles = await this.roleRepo.find({ relations: ['permissions'] });
|
||||
const allRoles = await this.roleRepo.find({ relations: ['permissions', 'users'] });
|
||||
|
||||
// Step 3: 构建角色-权限关联
|
||||
// Step 3: 合并旧角色并构建新的职责权限矩阵
|
||||
for (const preset of PRESET_ROLES) {
|
||||
const role = allRoles.find((r) => r.name === preset.name || r.code === preset.code);
|
||||
const matchesPreset = (role: Role) =>
|
||||
role.name === preset.name ||
|
||||
role.code === preset.code ||
|
||||
preset.legacyNames?.includes(role.name) ||
|
||||
preset.legacyCodes?.includes(role.code);
|
||||
const candidates = allRoles.filter(matchesPreset);
|
||||
const role = candidates.find((candidate) => candidate.code === preset.code) ?? candidates[0];
|
||||
if (!role) continue;
|
||||
if (role.code !== preset.code) {
|
||||
|
||||
const duplicateRoles = candidates.filter((candidate) => candidate.id !== role.id);
|
||||
if (duplicateRoles.length > 0) {
|
||||
for (const duplicate of duplicateRoles) {
|
||||
for (const relatedUser of duplicate.users ?? []) {
|
||||
const user = await this.userRepo.findOne({
|
||||
where: { id: relatedUser.id },
|
||||
relations: ['roles'],
|
||||
});
|
||||
if (!user) continue;
|
||||
const remainingRoles = (user.roles ?? []).filter(
|
||||
(assignedRole) => assignedRole.id !== duplicate.id && assignedRole.id !== role.id,
|
||||
);
|
||||
user.roles = [...remainingRoles, role];
|
||||
await this.userRepo.save(user);
|
||||
}
|
||||
await this.roleRepo.remove(duplicate);
|
||||
}
|
||||
}
|
||||
|
||||
if (role.code !== preset.code || role.name !== preset.name || role.description !== preset.description) {
|
||||
role.code = preset.code;
|
||||
role.name = preset.name;
|
||||
role.description = preset.description;
|
||||
role.isSystem = preset.isSystem;
|
||||
role.status = 1;
|
||||
await this.roleRepo.save(role);
|
||||
}
|
||||
|
||||
@@ -265,12 +322,11 @@ export class RbacService {
|
||||
);
|
||||
}
|
||||
|
||||
// 系统角色只补齐预置权限,不移除管理员手动授予的额外权限。
|
||||
// 这样新增权限(例如 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];
|
||||
// 系统预置角色必须严格遵循职责矩阵;额外授权请创建自定义角色叠加。
|
||||
const currentIds = role.permissions.map((permission) => permission.id).sort((a, b) => a - b);
|
||||
const targetIds = perms.map((permission) => permission.id).sort((a, b) => a - b);
|
||||
if (currentIds.join(',') !== targetIds.join(',')) {
|
||||
role.permissions = perms;
|
||||
await this.roleRepo.save(role);
|
||||
}
|
||||
}
|
||||
@@ -285,7 +341,7 @@ export class RbacService {
|
||||
passwordHash: hash,
|
||||
name: '管理员',
|
||||
});
|
||||
const superAdminRole = allRoles.find((r) => r.name === '超管');
|
||||
const superAdminRole = allRoles.find((r) => r.code === 'super_admin');
|
||||
if (superAdminRole) {
|
||||
adminUser.roles = [superAdminRole];
|
||||
}
|
||||
@@ -555,7 +611,6 @@ 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();
|
||||
|
||||
@@ -594,8 +649,8 @@ 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 teacherRoleCodes = ['teacher', 'super_admin'];
|
||||
const teacherRoleNames = ['任课老师', '老师', '超级管理员', '超管'];
|
||||
|
||||
const qb = this.userRepo
|
||||
.createQueryBuilder('u')
|
||||
|
||||
54
apps/server/src/rbac/rbac.teacher-workspace.spec.ts
Normal file
54
apps/server/src/rbac/rbac.teacher-workspace.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { RbacService } from './rbac.service';
|
||||
|
||||
describe('RbacService getTeacherWorkspace', () => {
|
||||
it('loads today schedules for every assigned class without requiring schedule.teacherId', async () => {
|
||||
const queryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 12,
|
||||
classId: 8,
|
||||
classroomId: 3,
|
||||
teacherId: null,
|
||||
weekDay: new Date().getDay() || 7,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
subject: '数学',
|
||||
scheduleType: 'INTERNAL',
|
||||
},
|
||||
]),
|
||||
};
|
||||
const classTeacherRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
classId: 8,
|
||||
userId: 21,
|
||||
roleType: 'subject_teacher',
|
||||
subject: '数学',
|
||||
class: { id: 8, name: '一班', code: 'C001' },
|
||||
},
|
||||
]),
|
||||
};
|
||||
const classStudentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const classScheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(queryBuilder) };
|
||||
const service = new RbacService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
classStudentRepo as never,
|
||||
classTeacherRepo as never,
|
||||
classScheduleRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const result = await service.getTeacherWorkspace(21);
|
||||
|
||||
expect(result.todaySchedules).toHaveLength(1);
|
||||
expect(queryBuilder.andWhere).not.toHaveBeenCalledWith('cs.teacherId = :userId', {
|
||||
userId: 21,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user