forked from wangziqi/gongxue-base
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页 - financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等 - 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time) - rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项 Reviewed-by: OCR (open-codereview.ai)
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
import { Injectable, Logger } 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, AttendanceSession } from '../entities';
|
||
import {
|
||
PRESET_ROLES,
|
||
PRESET_PERMISSIONS,
|
||
DEPRECATED_PERMISSION_CODES,
|
||
DEPRECATED_PERMISSION_CODE_SET,
|
||
getChinaDateParts,
|
||
} from './rbac-presets';
|
||
|
||
@Injectable()
|
||
export class RbacService {
|
||
private readonly logger = new Logger(RbacService.name);
|
||
|
||
constructor(
|
||
@InjectRepository(Permission) private permRepo: Repository<Permission>,
|
||
@InjectRepository(Role) private roleRepo: Repository<Role>,
|
||
@InjectRepository(User) private userRepo: Repository<User>,
|
||
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||
@InjectRepository(ClassSchedule) private classScheduleRepo: Repository<ClassSchedule>,
|
||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||
@InjectRepository(AttendanceSession)
|
||
private attendanceSessionRepo: Repository<AttendanceSession>,
|
||
) {}
|
||
|
||
async findAllRoles(): Promise<Role[]> {
|
||
return this.roleRepo.find({
|
||
relations: ['permissions'],
|
||
order: { id: 'ASC' },
|
||
});
|
||
}
|
||
|
||
async findRoleById(id: number): Promise<Role> {
|
||
return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
|
||
}
|
||
|
||
async getTeacherWorkspace(userId: number) {
|
||
// Find all classes where this user is a teacher
|
||
const teacherAssignments = await this.classTeacherRepo.find({
|
||
where: { userId },
|
||
relations: ['class'],
|
||
});
|
||
|
||
const classIds = [...new Set(teacherAssignments.map((t) => t.classId))];
|
||
|
||
if (classIds.length === 0) {
|
||
return { assignedClasses: [], todaySchedules: [], myStudents: [] };
|
||
}
|
||
|
||
const assignedClasses = teacherAssignments.map((t) => ({
|
||
classId: t.classId,
|
||
className: t.class?.name || '',
|
||
classCode: t.class?.code || '',
|
||
roleType: t.roleType,
|
||
subject: t.subject,
|
||
}));
|
||
|
||
// Get today's China business date and day of week (1=Monday, 7=Sunday)
|
||
const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts();
|
||
|
||
const todaySchedules = await this.classScheduleRepo
|
||
.createQueryBuilder('cs')
|
||
.where('cs.classId IN (:...classIds)', { classIds })
|
||
.andWhere('cs.weekDay = :weekDay', { weekDay: adjustedWeekDay })
|
||
.andWhere('cs.startDate <= :today', { today: todayStr })
|
||
.andWhere('cs.endDate >= :today', { today: todayStr })
|
||
.andWhere('cs.status = :status', { status: 'active' })
|
||
.orderBy('cs.startTime', 'ASC')
|
||
.getMany();
|
||
|
||
const classStudents = await this.classStudentRepo.find({
|
||
where: { classId: In(classIds), status: 'active' },
|
||
relations: ['student', 'class'],
|
||
});
|
||
|
||
const myStudents = classStudents.map((cs) => ({
|
||
studentId: cs.studentId,
|
||
studentName: cs.student?.name || '',
|
||
studentNo: cs.student?.studentNo || '',
|
||
className: cs.class?.name || '',
|
||
classId: cs.classId,
|
||
joinDate: cs.joinDate,
|
||
}));
|
||
|
||
return {
|
||
assignedClasses,
|
||
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,
|
||
subject: s.subject,
|
||
scheduleType: s.scheduleType,
|
||
})),
|
||
myStudents,
|
||
};
|
||
}
|
||
}
|
||
|
||
@Injectable()
|
||
export class RbacSeedService {
|
||
private readonly logger = new Logger(RbacSeedService.name);
|
||
|
||
constructor(
|
||
@InjectRepository(Permission) private permRepo: Repository<Permission>,
|
||
@InjectRepository(Role) private roleRepo: Repository<Role>,
|
||
@InjectRepository(User) private userRepo: Repository<User>,
|
||
) {}
|
||
|
||
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> {
|
||
// 仅当角色表为空(首次初始化)或显式设置 SEED_ROLES=true 时才播种;
|
||
// 否则跳过,避免每次启动都无条件重激活角色/权限,覆盖管理员的自定义调整。
|
||
const forceSeed = process.env.SEED_ROLES === 'true';
|
||
const roleCount = await this.roleRepo.count();
|
||
if (roleCount > 0 && !forceSeed) {
|
||
this.logger.log(
|
||
`角色表已有 ${roleCount} 条记录,跳过种子数据重激活(如需强制播种请设置 SEED_ROLES=true)`,
|
||
);
|
||
await this.ensureDefaultAdmin();
|
||
return;
|
||
}
|
||
|
||
for (const p of PRESET_PERMISSIONS) {
|
||
const exists = await this.permRepo.findOne({ where: { code: p.code } });
|
||
if (!exists) {
|
||
await this.permRepo.save(this.permRepo.create(p));
|
||
}
|
||
}
|
||
const deprecatedUserDeletePermission = await this.permRepo.findOne({
|
||
where: { code: 'user:delete' },
|
||
});
|
||
const allPerms = (await this.permRepo.find()).filter(
|
||
(permission) =>
|
||
permission.code !== 'user:delete' && !DEPRECATED_PERMISSION_CODE_SET.has(permission.code),
|
||
);
|
||
|
||
for (const r of PRESET_ROLES) {
|
||
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({
|
||
name: r.name,
|
||
code: r.code,
|
||
description: r.description,
|
||
isSystem: r.isSystem,
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
const allRoles = await this.roleRepo.find({ relations: ['permissions', 'users'] });
|
||
|
||
if (deprecatedUserDeletePermission) {
|
||
for (const role of allRoles) {
|
||
const permissions = role.permissions ?? [];
|
||
if (permissions.some((permission) => permission.id === deprecatedUserDeletePermission.id)) {
|
||
role.permissions = permissions.filter(
|
||
(permission) => permission.id !== deprecatedUserDeletePermission.id,
|
||
);
|
||
await this.roleRepo.save(role);
|
||
}
|
||
}
|
||
await this.permRepo.remove(deprecatedUserDeletePermission);
|
||
}
|
||
|
||
const deprecatedPermissions = await this.permRepo.find({
|
||
where: { code: In([...DEPRECATED_PERMISSION_CODES]) },
|
||
});
|
||
if (deprecatedPermissions.length > 0) {
|
||
const deprecatedIds = new Set(deprecatedPermissions.map((permission) => permission.id));
|
||
for (const role of allRoles) {
|
||
const permissions = role.permissions ?? [];
|
||
if (permissions.some((permission) => deprecatedIds.has(permission.id))) {
|
||
role.permissions = permissions.filter((permission) => !deprecatedIds.has(permission.id));
|
||
await this.roleRepo.save(role);
|
||
}
|
||
}
|
||
await this.permRepo.remove(deprecatedPermissions);
|
||
this.logger.log(`已清理废弃权限点: ${deprecatedPermissions.map((p) => p.code).join(', ')}`);
|
||
}
|
||
|
||
for (const preset of PRESET_ROLES) {
|
||
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;
|
||
|
||
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);
|
||
}
|
||
|
||
let perms: Permission[];
|
||
if (preset.permissionGroups.length === 0) {
|
||
// 超管:全部权限
|
||
perms = allPerms;
|
||
} else {
|
||
// 按 group 匹配 + 额外权限(如老师的 student:view)
|
||
const byGroup = allPerms.filter((p) => preset.permissionGroups.includes(p.group));
|
||
const byExtra = preset.extraPermissions
|
||
? allPerms.filter((p) => preset.extraPermissions!.includes(p.code))
|
||
: [];
|
||
perms = [...byGroup, ...byExtra].filter(
|
||
(p, i, arr) => arr.findIndex((x) => x.id === p.id) === i,
|
||
);
|
||
}
|
||
|
||
// 系统预置角色必须严格遵循职责矩阵;额外授权请创建自定义角色叠加。
|
||
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);
|
||
}
|
||
}
|
||
|
||
await this.ensureDefaultAdmin();
|
||
|
||
this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`);
|
||
}
|
||
|
||
/**
|
||
* 首次初始化时创建默认管理员。
|
||
* 即使跳过角色/权限播种(角色表已有数据)也会执行,避免出现「有角色但无管理员」的状态。
|
||
*/
|
||
private async ensureDefaultAdmin(): Promise<void> {
|
||
const count = await this.userRepo.count();
|
||
if (count > 0) return;
|
||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||
// 生产环境禁止使用默认弱口令
|
||
if (!adminPassword) {
|
||
if (process.env.NODE_ENV === 'production') {
|
||
throw new Error('生产环境必须设置 ADMIN_PASSWORD 后再初始化默认管理员');
|
||
}
|
||
this.logger.warn('ADMIN_PASSWORD 未设置,开发环境使用默认密码 admin123');
|
||
}
|
||
const password = adminPassword || 'admin123';
|
||
const hash = await bcrypt.hash(password, 10);
|
||
const adminUser = this.userRepo.create({
|
||
username: 'admin',
|
||
passwordHash: hash,
|
||
name: '管理员',
|
||
});
|
||
const superAdminRole = await this.roleRepo.findOne({ where: { code: 'super_admin' } });
|
||
if (superAdminRole) {
|
||
adminUser.roles = [superAdminRole];
|
||
}
|
||
await this.userRepo.save(adminUser);
|
||
// 不打印密码,避免凭据落入日志
|
||
this.logger.log('已创建默认管理员: admin(请妥善保管密码)');
|
||
}
|
||
|
||
}
|