import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as fs from 'fs'; import * as path from 'path'; import { NotificationsService } from '../notifications/notifications.service'; import { Student } from '../entities/student.entity'; import { StudentProfile } from '../entities/student-profile.entity'; import { StudentEnrollment } from '../entities/student-enrollment.entity'; import { ExamScore } from '../entities/exam-score.entity'; import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; import { ArchiveAttachment } from '../entities/archive-attachment.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { UpsertProfileDto, CreateEnrollmentDto, UpdateEnrollmentDto, CreateExamScoreDto, UpdateExamScoreDto, CreateLearningRecordDto, UpdateLearningRecordDto, UpsertResultDto, } from './dto/archive.dto'; @Injectable() export class ArchiveService { constructor( @InjectRepository(Student) private studentRepo: Repository, @InjectRepository(StudentProfile) private profileRepo: Repository, @InjectRepository(StudentEnrollment) private enrollmentRepo: Repository, @InjectRepository(ExamScore) private examScoreRepo: Repository, @InjectRepository(LearningRecord) private learningRecordRepo: Repository, @InjectRepository(ResultArchive) private resultRepo: Repository, @InjectRepository(ArchiveAttachment) private attachmentRepo: Repository, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, private readonly notificationsService: NotificationsService, ) {} get uploadDir(): string { const base = process.env.UPLOAD_DIR || './uploads'; return path.resolve(base, 'archive'); } private resolveAttachmentPath(filePath: string): string { const normalizedPath = filePath.replace(/\\/g, '/'); const fullPath = normalizedPath.startsWith('uploads/') ? path.resolve(process.cwd(), normalizedPath) : path.resolve(this.uploadDir, normalizedPath); const allowedRoots = [this.uploadDir, path.resolve(process.cwd(), 'uploads', 'archive')]; if ( !allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`)) ) { throw new BadRequestException('路径非法'); } return fullPath; } async getProfile(studentId: number) { const student = await this.studentRepo.findOne({ where: { id: studentId }, relations: ['organization'], }); if (!student) throw new NotFoundException('学生不存在'); const [ profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments, attendances, ] = await Promise.all([ this.profileRepo.findOne({ where: { studentId } }), this.enrollmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }), this.examScoreRepo.find({ where: { studentId, status: 'active' }, relations: ['exam', 'exam.class'], order: { examDate: 'DESC' }, }), this.learningRecordRepo.find({ where: { studentId, status: 'active' }, order: { recordDate: 'DESC' } }), this.resultRepo.findOne({ where: { studentId } }), this.attachmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }), this.attendanceRepo.find({ where: { studentId }, relations: ['schedule', 'class'], order: { attendanceDate: 'DESC', punchTime: 'DESC' }, }), ]); return { student, profile: profileRaw, enrollments, examScores, learningRecords, result: resultArchive, attachments, attendances, }; } async upsertProfile(studentId: number, dto: UpsertProfileDto) { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); let profile = await this.profileRepo.findOne({ where: { studentId } }); if (profile) { Object.assign(profile, dto); } else { profile = this.profileRepo.create({ ...dto, studentId }); } return this.profileRepo.save(profile); } async addEnrollment(studentId: number, dto: CreateEnrollmentDto) { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); const entity = this.enrollmentRepo.create({ ...dto, studentId }); return this.enrollmentRepo.save(entity); } async updateEnrollment(id: number, dto: UpdateEnrollmentDto) { const entity = await this.enrollmentRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('报名记录不存在'); Object.assign(entity, dto); return this.enrollmentRepo.save(entity); } async deleteEnrollment(id: number) { const entity = await this.enrollmentRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('报名记录不存在'); if (entity.status === 'archived') throw new BadRequestException('报名记录已归档'); await this.enrollmentRepo.update(id, { status: 'archived' }); return { message: '已归档' }; } private async assertEnrollmentBelongsToStudent(studentId: number, enrollmentId?: number) { if (enrollmentId === undefined) return; const enrollment = await this.enrollmentRepo.findOne({ where: { id: enrollmentId, studentId }, }); if (!enrollment) throw new BadRequestException('报名记录不属于该学生'); } async addExamScore(studentId: number, dto: CreateExamScoreDto) { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); await this.assertEnrollmentBelongsToStudent(studentId, dto.enrollmentId); const entity = this.examScoreRepo.create({ ...dto, studentId }); return this.examScoreRepo.save(entity); } async updateExamScore(id: number, dto: UpdateExamScoreDto) { const entity = await this.examScoreRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('考试成绩不存在'); if (entity.examId) throw new BadRequestException('考试管理同步成绩请在考试管理中修改'); await this.assertEnrollmentBelongsToStudent(entity.studentId, dto.enrollmentId); Object.assign(entity, dto); return this.examScoreRepo.save(entity); } async deleteExamScore(id: number) { const entity = await this.examScoreRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('考试成绩不存在'); if (entity.examId) throw new BadRequestException('考试管理同步成绩不能在学生档案中归档'); if (entity.status === 'archived') throw new BadRequestException('考试成绩已归档'); await this.examScoreRepo.update(id, { status: 'archived' }); return { message: '已归档' }; } async addLearningRecord(studentId: number, dto: CreateLearningRecordDto) { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); const entity = this.learningRecordRepo.create({ ...dto, studentId }); return this.learningRecordRepo.save(entity); } async updateLearningRecord(id: number, dto: UpdateLearningRecordDto) { const entity = await this.learningRecordRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('学习记录不存在'); Object.assign(entity, dto); return this.learningRecordRepo.save(entity); } async deleteLearningRecord(id: number) { const entity = await this.learningRecordRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('学习记录不存在'); if (entity.status === 'archived') throw new BadRequestException('学习记录已归档'); await this.learningRecordRepo.update(id, { status: 'archived' }); return { message: '已归档' }; } async upsertResult(studentId: number, dto: UpsertResultDto) { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); let result = await this.resultRepo.findOne({ where: { studentId } }); if (result) { Object.assign(result, dto); } else { result = this.resultRepo.create({ ...dto, studentId }); } return this.resultRepo.save(result); } async addAttachment(studentId: number, file: Express.Multer.File, category: string) { if (!file?.buffer || !file.originalname) throw new BadRequestException('请选择附件文件'); const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); const uploadDir = this.uploadDir; if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } const ext = path.extname(file.originalname); const filename = `${studentId}_${Date.now()}${ext}`; const filePath = path.join(uploadDir, filename); fs.writeFileSync(filePath, file.buffer); const entity = this.attachmentRepo.create({ studentId, category, fileName: file.originalname, filePath: filename, fileSize: file.size, mimeType: file.mimetype, }); return this.attachmentRepo.save(entity); } async getAttachmentFile(studentId: number, id: number) { const entity = await this.attachmentRepo.findOne({ where: { id, studentId } }); if (!entity) throw new NotFoundException('附件不存在'); const fullPath = this.resolveAttachmentPath(entity.filePath); if (!fs.existsSync(fullPath)) throw new NotFoundException('附件文件丢失'); return { fullPath, fileName: entity.fileName || path.basename(fullPath), mimeType: entity.mimeType || 'application/octet-stream', }; } async deleteAttachment(id: number) { const entity = await this.attachmentRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('附件不存在'); if (entity.status === 'archived') throw new BadRequestException('附件已归档'); await this.attachmentRepo.update(id, { status: 'archived' }); return { message: '已归档' }; } }