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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user