Files
gongxue-base/apps/server/src/rbac/rbac-seed.service.ts
wangziqi f50301148d fix(correctness): 并发/事务/实体/时区/状态一致性修复
由 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)
2026-08-09 21:29:54 +08:00

307 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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请妥善保管密码');
}
}