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

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