Files
gongxue-base/apps/server/src/classes/classes.service.ts
wangziqi 257c2065b8
All checks were successful
CI / check (pull_request) Successful in 4m26s
Dependency Check / check (pull_request) Successful in 2m48s
fix(server): 迁移超长科目备份移到截断前、keepPlus/rels 解析加固、钉钉导入失败补偿
- 迁移:超长科目原值备份移到 step 2 拆分 UPDATE 之前(原位置截断后按长度查不到原值,备份无效)
- keepPlus:'+' 后紧跟公式触发符(- + = @ 等)不保留前缀,写回 Excel 不会被当公式
- workbook rels:逐元素解析任意属性顺序/引号,声明大小超限拒绝,防文件名绕过与内存放大
- create():钉钉导入失败时补偿删除刚建班级(成员外键 CASCADE),避免孤儿班级
2026-08-11 12:46:12 +08:00

583 lines
21 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,
NotFoundException,
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, Like, Repository } from 'typeorm';
import { escapeLike } from '../common/like-escape';
import {
Class,
ClassStudent,
ClassTeacher,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
Exam,
Student,
StudentDingMapping,
} from '../entities';
import { ClassesQueriesService, isDuplicateEntryError } from './classes-queries.service';
import { normalizeDateOnly } from '../database/date-normalization';
import dayjs from '../common/dayjs';
import {
CreateClassDto,
UpdateClassDto,
QueryClassDto,
AddTeacherDto,
QueryClassScheduleDto,
QueryClassAttendanceSummaryDto,
MAX_TEACHER_SUBJECTS,
MAX_SUBJECT_LENGTH,
} from './dto/class.dto';
import type { ClassRosterCreateRow, ClassRosterImportRow } from './class-roster-import';
interface RawStudentCount {
classId: string;
count: string;
}
/** 归一化多科目:去空白、去重、超限丢弃(长度上限由 dto MAX_SUBJECT_LENGTH 校验前置拦截)。
* 去重键统一小写class_teacher 唯一索引在 MySQL 默认 collation 下大小写不敏感,
* 否则 'Math'+'math' 会通过此处去重却在 save 时撞 1062保留首个原始大小写展示。 */
export function normalizeTeacherSubjects(subjects?: string[]): string[] {
if (!subjects) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const raw of subjects) {
const trimmed = raw?.trim() ?? '';
if (!trimmed || trimmed.length > MAX_SUBJECT_LENGTH) continue;
const key = trimmed.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(trimmed);
if (result.length >= MAX_TEACHER_SUBJECTS) break;
}
return result;
}
/** 解析老师科目:优先 subjects归一化后为空时回退兼容旧字段 subject含空白项场景。 */
function resolveTeacherSubjects(
subjects: string[] | undefined,
subject: string | undefined,
): string[] {
const normalized = normalizeTeacherSubjects(subjects);
if (normalized.length > 0) return normalized;
if (subject) return normalizeTeacherSubjects([subject]);
return [];
}
@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>,
private dataSource: DataSource,
@InjectRepository(Exam)
private examRepo: Repository<Exam>,
private queries: ClassesQueriesService,
) {}
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 agentSearchClasses(
userId: number,
canManageAll: boolean,
query: { keyword?: string; status?: string; limit?: number },
) {
const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll);
return this.queries.agentSearchClasses(accessibleClassIds, query);
}
async batchImportStudents(
classId: number,
users: Array<{ dingUserId: string; name: string; mobile?: string }>,
): Promise<{ imported: number; skipped: number; conflicts: number }> {
return this.queries.batchImportStudents(classId, users);
}
async previewRosterImport(classId: number, rows: ClassRosterImportRow[]) {
return this.queries.previewRosterImport(classId, rows);
}
async commitRosterImport(
classId: number,
input: { addStudentIds?: number[]; createRows?: ClassRosterCreateRow[] },
) {
return this.queries.commitRosterImport(classId, input);
}
async getSchedule(classId: number, query: QueryClassScheduleDto) {
return this.queries.getSchedule(classId, query);
}
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
return this.queries.getAttendanceSummary(classId, query);
}
async findAll(query: QueryClassDto, accessibleClassIds?: number[]) {
const where: Record<string, unknown> = {};
if (query.status) where.status = query.status;
if (query.classType) where.classType = query.classType;
if (query.keyword) where.name = Like(`%${escapeLike(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'],
order: { id: 'ASC' },
});
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,
name: t.user?.name,
username: t.user?.username,
roleType: t.roleType,
subject: t.subject,
subjects: t.subject ? [t.subject] : [],
})),
studentCount: students.filter((s) => s.status === 'active').length,
};
}
async create(dto: CreateClassDto) {
const { studentIds, teachers, users, ...classData } = dto;
// 先校验老师科目(任课老师必须至少一个科目),避免 400 时已写入班级留下孤儿数据
const teacherPlans = (teachers ?? []).map((t) => ({
userId: t.userId,
roleType: t.roleType,
subjects: resolveTeacherSubjects(t.subjects, t.subject),
}));
for (const plan of teacherPlans) {
if (plan.roleType === 'subject_teacher' && plan.subjects.length === 0) {
throw new BadRequestException('任课老师至少需要一个科目');
}
}
// 班级/学生/教师写入整体包进事务:任一环节失败(并发唯一索引冲突、断连等)整体回滚,
// 避免「班级与已加入学生已落库、教师写入失败」留下孤儿数据。
// batchImportStudents钉钉导入自带事务保持事务外调用失败行为与原来一致。
const savedClassId = await this.dataSource.transaction(async (manager) => {
const cls = manager.create(Class, {
...classData,
startDate: normalizeDateOnly(classData.startDate) ?? undefined,
endDate: normalizeDateOnly(classData.endDate) ?? undefined,
});
const saved = await manager.save(cls);
// add students
if (studentIds?.length) {
const entries = studentIds.map((sid: number) =>
manager.create(ClassStudent, {
classId: saved.id,
studentId: sid,
joinDate: dayjs().utcOffset(8).format('YYYY-MM-DD'),
}),
);
await manager.save(ClassStudent, entries);
}
// add teachers任课老师一行一科目其他角色单行无科目
if (teacherPlans.length) {
const entries: ClassTeacher[] = [];
const seen = new Set<string>();
for (const plan of teacherPlans) {
if (plan.roleType === 'subject_teacher') {
for (const subject of plan.subjects) {
// 唯一索引 collation 大小写不敏感,去重键统一小写避免 'Math'+'math' 撞 1062
const key = `${plan.userId}:${plan.roleType}:${subject.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
entries.push(
manager.create(ClassTeacher, {
classId: saved.id,
userId: plan.userId,
roleType: plan.roleType,
subject,
}),
);
}
} else {
const key = `${plan.userId}:${plan.roleType}:`;
if (seen.has(key)) continue;
seen.add(key);
entries.push(
manager.create(ClassTeacher, {
classId: saved.id,
userId: plan.userId,
roleType: plan.roleType,
subject: '',
}),
);
}
}
if (entries.length) await manager.save(ClassTeacher, entries);
// sync head/life/academic teacher IDs事务内用 manager 保证同事务可见性)
await this.syncClassTeacherIds(saved.id, manager);
}
return saved.id;
});
// batch import students by dingUserIds自带事务事务外调用
if (users?.length) {
try {
await this.batchImportStudents(savedClassId, users);
} catch (error) {
// 钉钉导入失败会抛错,班级已在事务中提交——补偿删除刚创建的班级(学生/教师成员
// 外键均 CASCADE避免留下孤儿班级且前端重试会重复建班
try {
await this.classRepo.delete(savedClassId);
} catch {
// 补偿删除失败:保留班级,优先抛原始错误
}
throw error;
}
}
return this.findOne(savedClassId);
}
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('班级不存在');
if (cls.isArchived) throw new BadRequestException('班级已归档');
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('班级不存在');
if (!cls.isArchived) throw new BadRequestException('班级未归档');
await this.classRepo.update(id, { isArchived: false });
return { success: true };
}
/** 归档班级(兼容旧删除入口,不物理删除) */
async remove(id: number) {
return this.archive(id);
}
/** 永久删除班级(仅已归档) */
async purge(id: number) {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
if (!cls.isArchived) throw new BadRequestException('仅已归档班级可以永久删除,请先归档');
const [studentCount, teacherCount, scheduleCount, examCount, sessionCount, attendanceCount] =
await Promise.all([
this.classStudentRepo.count({ where: { classId: id } }),
this.classTeacherRepo.count({ where: { classId: id } }),
this.scheduleRepo.count({ where: { classId: id } }),
this.examRepo.count({ where: { classId: id } }),
this.attendanceSessionRepo.count({ where: { classId: id } }),
this.attendanceRepo.count({ where: { classId: id } }),
]);
const references: string[] = [];
if (studentCount > 0) references.push('班级学生');
if (teacherCount > 0) references.push('任课教师');
if (scheduleCount > 0) references.push('排课');
if (examCount > 0) references.push('考试');
if (sessionCount > 0 || attendanceCount > 0) references.push('考勤记录');
if (references.length > 0) {
throw new BadRequestException(`该班级存在关联数据(${references.join('、')}),无法永久删除`);
}
await this.classRepo.delete(id);
return { message: '已永久删除班级(不可恢复)' };
}
async getStudents(classId: number) {
return this.classStudentRepo.find({
where: { classId },
relations: ['student'],
order: { createdAt: 'ASC' as const },
});
}
async addStudents(classId: number, studentIds: number[]) {
const uniqueStudentIds = [...new Set(studentIds)];
if (uniqueStudentIds.length === 0) return { added: 0, skipped: 0 };
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new NotFoundException('班级不存在');
const students = await this.studentRepo.find({ where: { id: In(uniqueStudentIds) } });
if (students.length !== uniqueStudentIds.length) {
throw new NotFoundException('部分学生不存在');
}
const existing = await this.classStudentRepo.find({
where: { classId, studentId: In(uniqueStudentIds) },
});
const existingByStudentId = new Map(
existing.map((classStudent) => [classStudent.studentId, classStudent]),
);
const today = dayjs().utcOffset(8).format('YYYY-MM-DD');
let skipped = 0;
const memberships = uniqueStudentIds.flatMap((studentId) => {
const current = existingByStudentId.get(studentId);
if (current?.status === 'active') {
skipped++;
return [];
}
if (current) {
current.status = 'active';
current.joinDate = today;
current.leaveDate = null;
return [current];
}
return [
this.classStudentRepo.create({
classId,
studentId,
status: 'active',
joinDate: today,
}),
];
});
if (memberships.length) await this.classStudentRepo.save(memberships);
return { added: memberships.length, skipped };
}
async removeStudent(classId: number, studentId: number) {
const membership = await this.classStudentRepo.findOne({
where: { classId, studentId },
});
if (!membership) throw new NotFoundException('学生不在该班级');
if (membership.status !== 'active') throw new BadRequestException('学生已离班');
membership.status = 'left';
membership.leaveDate = dayjs().utcOffset(8).format('YYYY-MM-DD');
await this.classStudentRepo.save(membership);
return { success: true };
}
async getTeachers(classId: number) {
const rows = await this.classTeacherRepo.find({
where: { classId },
relations: ['user'],
order: { id: 'ASC' },
});
// 一行一科目:直接返回原始行,同一老师多科时按科目拆成多行
return rows.map((t) => ({
id: t.id,
userId: t.userId,
username: t.user?.username,
name: t.user?.name,
roleType: t.roleType,
subject: t.subject,
subjects: t.subject ? [t.subject] : [],
}));
}
async addTeacher(classId: number, dto: AddTeacherDto) {
const cls = await this.classRepo.findOne({ where: { id: classId } });
if (!cls) throw new NotFoundException('班级不存在');
// 任课老师:一个科目一行;同科目已存在则跳过,允许追加新科目
if (dto.roleType === 'subject_teacher') {
const subjects = resolveTeacherSubjects(dto.subjects, dto.subject);
if (subjects.length === 0) {
throw new BadRequestException('任课老师至少需要一个科目');
}
const existing = await this.classTeacherRepo.find({
where: { classId, userId: dto.userId, roleType: dto.roleType },
});
const existingSubjects = new Set(
existing
.map((row) => row.subject?.trim().toLowerCase())
.filter((subject): subject is string => !!subject),
);
const entries: ClassTeacher[] = [];
for (const subject of subjects) {
// 大小写不敏感比对:与唯一索引 collation 一致
if (existingSubjects.has(subject.toLowerCase())) continue;
entries.push(
this.classTeacherRepo.create({
classId,
userId: dto.userId,
roleType: dto.roleType,
subject,
}),
);
}
// 逐行保存MySQL 多行 INSERT 是原子语句,任意一行撞唯一索引整批失败;
// 逐行可让并发下已存在的科目跳过1062 不中止 InnoDB 事务)、其余科目正常落库;
// 非重复错误(断连等)整体回滚,避免部分科目已落库而接口报错。
// 返回真实持久化的行。
const persisted: ClassTeacher[] = [];
if (entries.length) {
await this.dataSource.transaction(async (manager) => {
for (const entry of entries) {
try {
persisted.push(await manager.save(entry));
} catch (error) {
// 并发添加同一科目时唯一索引冲突:该行已存在,跳过即可
if (!isDuplicateEntryError(error)) throw error;
}
}
});
}
await this.syncClassTeacherIds(classId);
return persisted;
}
// 班主任/生活老师/学服老师单行无科目subject 占位空串 '')。
// 先查重快速失败;并发下两个请求都可能通过查重,由 4 列唯一索引
// (class_id,user_id,role_type,subject) 兜底save 捕获 1062 转 400
// TypeORM 0.3.x 无事务时不可用悲观锁,故不依赖锁串行化)。
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: '',
});
try {
await this.classTeacherRepo.save(entry);
} catch (error) {
if (!isDuplicateEntryError(error)) throw error;
throw new BadRequestException('该教师已分配此角色');
}
await this.syncClassTeacherIds(classId);
return [entry];
}
async removeTeacher(classId: number, userId: number) {
const assignments = await this.classTeacherRepo.find({ where: { classId, userId } });
if (assignments.length === 0) throw new NotFoundException('教师未分配到该班级');
await this.classTeacherRepo.delete({ classId, userId });
await this.syncClassTeacherIds(classId);
return { success: true };
}
async removeTeacherAssignment(classId: number, assignmentId: number) {
const assignment = await this.classTeacherRepo.findOne({
where: { id: assignmentId, classId },
});
if (!assignment) throw new NotFoundException('教师角色分配不存在');
await this.classTeacherRepo.delete({ id: assignmentId, classId });
await this.syncClassTeacherIds(classId);
return { success: true };
}
private async syncClassTeacherIds(classId: number, manager?: EntityManager) {
const teachers = manager
? await manager.find(ClassTeacher, { where: { classId } })
: 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');
const patch = {
headTeacherId: head?.userId ?? null,
lifeTeacherId: life?.userId ?? null,
academicTeacherId: academic?.userId ?? null,
} as Partial<Class>;
if (manager) {
await manager.update(Class, classId, patch);
} else {
await this.classRepo.update(classId, patch);
}
}
}