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:
2026-07-12 22:59:03 +08:00
parent b6fca99390
commit cc4f4dae4e
69 changed files with 6262 additions and 1980 deletions

View File

@@ -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,

View File

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

View File

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

View File

@@ -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')

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