Files
gongxue-base/apps/server/src/classes/classes.service.ts
wangziqi cc4f4dae4e 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>
2026-07-12 22:59:03 +08:00

447 lines
14 KiB
TypeScript

import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Like } from 'typeorm';
import {
Class,
ClassStudent,
ClassTeacher,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
Classroom,
Student,
StudentDingMapping,
} from '../entities';
import { normalizeDateOnly } from '../database/date-normalization';
import {
CreateClassDto,
UpdateClassDto,
QueryClassDto,
AddTeacherDto,
QueryClassScheduleDto,
QueryClassAttendanceSummaryDto,
BatchImportStudentsDto,
} from './dto/class.dto';
interface RawStudentCount {
classId: string;
count: string;
}
@Injectable()
export class ClassesService {
constructor(
@InjectRepository(Class)
private classRepo: Repository<Class>,
@InjectRepository(ClassStudent)
private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(ClassTeacher)
private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(ClassSchedule)
private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceRecord)
private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(AttendanceSession)
private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectRepository(Student)
private studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping)
private studentDingMappingRepo: Repository<StudentDingMapping>,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
if (canManageAll) return;
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
if (!assignment) throw new ForbiddenException('只能访问自己被分配的班级');
}
async findAll(query: QueryClassDto, accessibleClassIds?: number[]) {
let where: Record<string, unknown> = {};
if (query.status) where.status = query.status;
if (query.classType) where.classType = query.classType;
if (query.keyword) where.name = Like(`%${query.keyword}%`);
// Default: hide archived, unless explicitly requested
where.isArchived = query.isArchived ?? false;
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
where.id = In(accessibleClassIds);
}
const classes = await this.classRepo.find({
where,
order: { createdAt: 'DESC' as const },
});
// count students per class
const studentCounts: RawStudentCount[] = await this.classStudentRepo
.createQueryBuilder('cs')
.select('cs.class_id', 'classId')
.addSelect('COUNT(cs.id)', 'count')
.where('cs.status = :status', { status: 'active' })
.groupBy('cs.class_id')
.getRawMany();
const countMap = new Map(studentCounts.map((r) => [Number(r.classId), Number(r.count)]));
return classes.map((c) => ({
...c,
studentCount: countMap.get(c.id) || 0,
}));
}
async findOne(id: number) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
const students = await this.classStudentRepo.find({
where: { classId: id },
relations: ['student'],
});
const teachers = await this.classTeacherRepo.find({
where: { classId: id },
relations: ['user'],
});
return {
...cls,
students: students.map((s) => ({
id: s.id,
studentId: s.studentId,
studentName: s.student?.name,
studentNo: s.student?.studentNo,
joinDate: s.joinDate,
leaveDate: s.leaveDate,
status: s.status,
})),
teachers: teachers.map((t) => ({
id: t.id,
userId: t.userId,
username: t.user?.username,
roleType: t.roleType,
subject: t.subject,
})),
studentCount: students.filter((s) => s.status === 'active').length,
};
}
async create(dto: CreateClassDto) {
const { studentIds, teachers, users, ...classData } = dto;
const cls = this.classRepo.create({
...classData,
startDate: normalizeDateOnly(classData.startDate) ?? undefined,
endDate: normalizeDateOnly(classData.endDate) ?? undefined,
});
const saved = await this.classRepo.save(cls);
// add students
if (studentIds?.length) {
const entries = studentIds.map((sid: number) =>
this.classStudentRepo.create({
classId: saved.id,
studentId: sid,
joinDate: new Date().toISOString().split('T')[0],
}),
);
await this.classStudentRepo.save(entries);
}
// add teachers
if (teachers?.length) {
const entries = teachers.map((t) =>
this.classTeacherRepo.create({
classId: saved.id,
userId: t.userId,
roleType: t.roleType,
subject: t.subject,
}),
);
await this.classTeacherRepo.save(entries);
// sync head/life/academic teacher IDs
await this.syncClassTeacherIds(saved.id);
}
// batch import students by dingUserIds
if (users?.length) {
await this.batchImportStudents(saved.id, users);
}
return this.findOne(saved.id);
}
async batchImportStudents(
classId: number,
users: Array<{
dingUserId: string;
name: string;
mobile?: string;
}>,
): Promise<{ imported: number; skipped: number }> {
const classEntity = await this.classRepo.findOne({ where: { id: classId } });
if (!classEntity) throw new NotFoundException('班级不存在');
if (users.length === 0) return { imported: 0, skipped: 0 };
const dingUserIds = users.map((u) => u.dingUserId);
// 1. Fetch all existing ding mappings in one query
const existingMappings = await this.studentDingMappingRepo.find({
where: { dingUserId: In(dingUserIds) },
});
const dingToStudentId = new Map(existingMappings.map((m) => [m.dingUserId, m.studentId]));
// 2. Batch create students for new dingUserIds
const newUsers = users.filter((u) => !dingToStudentId.has(u.dingUserId));
if (newUsers.length > 0) {
const newStudents = newUsers.map((u) =>
this.studentRepo.create({
name: u.name,
phone: u.mobile || `dt_${u.dingUserId}`,
status: 'active',
}),
);
const savedStudents = await this.studentRepo.save(newStudents);
const newMappings = savedStudents.map((s, i) =>
this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }),
);
await this.studentDingMappingRepo.save(newMappings);
for (let i = 0; i < newUsers.length; i++) {
dingToStudentId.set(newUsers[i].dingUserId, savedStudents[i].id);
}
}
// 3. Fetch existing class-student links in one query
const allStudentIds = Array.from(dingToStudentId.values());
const alreadyInClass = new Set<number>();
if (allStudentIds.length > 0) {
const existingClassStudents = await this.classStudentRepo.find({
where: { classId, studentId: In(allStudentIds) },
});
for (const cs of existingClassStudents) {
alreadyInClass.add(cs.studentId);
}
}
// 4. Batch insert new class-student records
const newClassStudents = allStudentIds
.filter((sid) => !alreadyInClass.has(sid))
.map((studentId) =>
this.classStudentRepo.create({
classId,
studentId,
status: 'active',
joinDate: new Date().toISOString().slice(0, 10),
}),
);
if (newClassStudents.length > 0) {
await this.classStudentRepo.save(newClassStudents);
}
return { imported: newClassStudents.length, skipped: alreadyInClass.size };
}
async update(id: number, dto: UpdateClassDto) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
await this.classRepo.update(id, {
...dto,
...(dto.startDate !== undefined
? { startDate: normalizeDateOnly(dto.startDate) ?? undefined }
: {}),
...(dto.endDate !== undefined
? { endDate: normalizeDateOnly(dto.endDate) ?? undefined }
: {}),
});
return this.findOne(id);
}
/** 归档班级(软删除) */
async archive(id: number) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
await this.classRepo.update(id, { isArchived: true });
return { success: true };
}
/** 取消归档 */
async restore(id: number) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
await this.classRepo.update(id, { isArchived: false });
return { success: true };
}
/** 物理删除班级(已归档的才能删除) */
async remove(id: number) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
if (!cls.isArchived) throw new BadRequestException('请先归档再删除');
const sessionCount = await this.attendanceSessionRepo.count({
where: { classId: id },
});
if (sessionCount > 0) {
throw new ConflictException(
`无法删除已产生 ${sessionCount} 个考勤场次的班级。请先取消或停用班级以保护历史考勤数据。`,
);
}
await this.classRepo.remove(cls);
return { success: true };
}
async getStudents(classId: number) {
return this.classStudentRepo.find({
where: { classId },
relations: ['student'],
order: { createdAt: 'ASC' as const },
});
}
async addStudents(classId: number, studentIds: number[]) {
const existing = await this.classStudentRepo.find({
where: { classId, studentId: In(studentIds) },
});
const existingIds = new Set(existing.map((e) => e.studentId));
const newIds = studentIds.filter((id) => !existingIds.has(id));
const entries = newIds.map((sid) =>
this.classStudentRepo.create({
classId,
studentId: sid,
joinDate: new Date().toISOString().split('T')[0],
}),
);
if (entries.length) await this.classStudentRepo.save(entries);
return { added: entries.length, skipped: studentIds.length - entries.length };
}
async removeStudent(classId: number, studentId: number) {
await this.classStudentRepo.delete({ classId, studentId });
return { success: true };
}
async getTeachers(classId: number) {
return this.classTeacherRepo.find({
where: { classId },
relations: ['user'],
});
}
async addTeacher(classId: number, dto: AddTeacherDto) {
const existing = await this.classTeacherRepo.findOne({
where: { classId, userId: dto.userId, roleType: dto.roleType },
});
if (existing) throw new BadRequestException('该教师已分配此角色');
const entry = this.classTeacherRepo.create({
classId,
userId: dto.userId,
roleType: dto.roleType,
subject: dto.subject,
});
await this.classTeacherRepo.save(entry);
await this.syncClassTeacherIds(classId);
return entry;
}
async removeTeacher(classId: number, userId: number) {
await this.classTeacherRepo.delete({ classId, userId });
await this.syncClassTeacherIds(classId);
return { success: true };
}
async removeTeacherAssignment(classId: number, assignmentId: number) {
await this.classTeacherRepo.delete({ id: assignmentId, classId });
await this.syncClassTeacherIds(classId);
return { success: true };
}
private async syncClassTeacherIds(classId: number) {
const teachers = await this.classTeacherRepo.find({ where: { classId } });
const head = teachers.find((t) => t.roleType === 'head_teacher');
const life = teachers.find((t) => t.roleType === 'life_teacher');
const academic = teachers.find((t) => t.roleType === 'academic_teacher');
await this.classRepo.update(classId, {
headTeacherId: head?.userId ?? null,
lifeTeacherId: life?.userId ?? null,
academicTeacherId: academic?.userId ?? null,
} as Partial<Class>);
}
async getSchedule(classId: number, query: QueryClassScheduleDto) {
const qb = this.scheduleRepo
.createQueryBuilder('cs')
.leftJoinAndSelect('cs.classroom', 'classroom')
.where('cs.classId = :classId', { classId });
if (query.startDate) {
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
}
const schedules = await qb
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.getMany();
return schedules.map((s) => ({
...s,
classroomName: (s.classroom as Classroom | undefined)?.name || null,
}));
}
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.where('ar.classId = :classId', { classId });
if (query.startDate) {
qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate });
}
if (query.endDate) {
qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate });
}
const rows = await qb.getMany();
const total = rows.length;
const present = rows.filter((r) => r.status === 'present').length;
const late = rows.filter((r) => r.status === 'late').length;
const absent = rows.filter((r) => r.status === 'absent').length;
const leave = rows.filter((r) => r.status === 'leave').length;
return {
total,
present,
late,
absent,
leave,
presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0,
absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0,
lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0,
leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0,
};
}
}