diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 4daf829..199db85 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -32,7 +32,13 @@ import { Notification, Department, UserDepartment, - } from './entities'; + StudentProfile, + StudentEnrollment, + ExamScore, + LearningRecord, + ResultArchive, + ArchiveAttachment, +} from './entities'; import { AuthModule } from './auth/auth.module'; import { RbacModule } from './rbac/rbac.module'; import { StudentsModule } from './students/students.module'; @@ -55,8 +61,8 @@ import { SyncModule } from './sync/sync.module'; import { NotificationsModule } from './notifications/notifications.module'; import { DepartmentsModule } from './departments/departments.module'; import { CommonModule } from './common/common.module'; +import { ArchiveModule } from './archive/archive.module'; import { CampusScopeMiddleware } from './common/campus-scope.middleware'; - @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), @@ -98,6 +104,12 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware'; Notification, Department, UserDepartment, + StudentProfile, + StudentEnrollment, + ExamScore, + LearningRecord, + ResultArchive, + ArchiveAttachment, ]; if (dbType === 'mysql') { return { @@ -140,6 +152,7 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware'; NotificationsModule, DepartmentsModule, CommonModule, + ArchiveModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, diff --git a/apps/server/src/archive/archive.controller.ts b/apps/server/src/archive/archive.controller.ts new file mode 100644 index 0000000..efafaa8 --- /dev/null +++ b/apps/server/src/archive/archive.controller.ts @@ -0,0 +1,349 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + UseGuards, + Request, + UseInterceptors, + UploadedFile, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Request as ExpressRequest } from 'express'; +import { ArchiveService } from './archive.service'; +import { + UpsertProfileDto, + CreateEnrollmentDto, + CreateExamScoreDto, + CreateLearningRecordDto, + UpsertResultDto, +} from './dto/archive.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { extractRequestInfo } from '../common/request-utils'; +import { RequirePermission } from '../auth/decorators/permission.decorator'; + +interface AuthenticatedRequest extends ExpressRequest { + user?: { id: number; username?: string }; +} + +@UseGuards(JwtAuthGuard) +@Controller('archive') +export class ArchiveController { + constructor( + private readonly archiveService: ArchiveService, + private readonly logService: OperationLogsService, + ) {} + + @Get(':studentId') + @RequirePermission('student:view') + async getProfile(@Param('studentId') studentId: string, @Request() req: AuthenticatedRequest) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.getProfile(+studentId); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '查看档案', + targetId: +studentId, + targetType: 'archive', + ipAddress, + userAgent, + }); + return result; + } + + @Put(':studentId/profile') + @RequirePermission('student:edit') + async upsertProfile( + @Param('studentId') studentId: string, + @Body() dto: UpsertProfileDto, + @Request() req: AuthenticatedRequest, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.upsertProfile(+studentId, dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '更新档案信息', + targetId: +studentId, + targetType: 'student_profile', + detail: JSON.stringify(dto), + ipAddress, + userAgent, + }); + return result; + } + + @Post(':studentId/enrollments') + @RequirePermission('student:edit') + async addEnrollment( + @Param('studentId') studentId: string, + @Body() dto: CreateEnrollmentDto, + @Request() req: AuthenticatedRequest, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.addEnrollment(+studentId, dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '添加报名记录', + targetId: result.id, + targetType: 'student_enrollment', + detail: `${dto.courseCategory} - ${dto.classType}`, + ipAddress, + userAgent, + }); + return result; + } + + @Put('enrollments/:id') + @RequirePermission('student:edit') + async updateEnrollment( + @Param('id') id: string, + @Body() dto: Partial, + @Request() req: AuthenticatedRequest, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.updateEnrollment(+id, dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '编辑报名记录', + targetId: +id, + targetType: 'student_enrollment', + detail: JSON.stringify(dto), + ipAddress, + userAgent, + }); + return result; + } + + @Delete('enrollments/:id') + @RequirePermission('student:edit') + async deleteEnrollment(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.deleteEnrollment(+id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '删除报名记录', + targetId: +id, + targetType: 'student_enrollment', + ipAddress, + userAgent, + }); + return result; + } + + @Post(':studentId/exam-scores') + @RequirePermission('student:edit') + async addExamScore( + @Param('studentId') studentId: string, + @Body() dto: CreateExamScoreDto, + @Request() req: AuthenticatedRequest, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.addExamScore(+studentId, dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '添加考试成绩', + targetId: result.id, + targetType: 'exam_score', + detail: `${dto.examType} - ${dto.subject}: ${dto.score}`, + ipAddress, + userAgent, + }); + return result; + } + + @Put('exam-scores/:id') + @RequirePermission('student:edit') + async updateExamScore( + @Param('id') id: string, + @Body() dto: Partial, + @Request() req: AuthenticatedRequest, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.updateExamScore(+id, dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '编辑考试成绩', + targetId: +id, + targetType: 'exam_score', + detail: JSON.stringify(dto), + ipAddress, + userAgent, + }); + return result; + } + + @Delete('exam-scores/:id') + @RequirePermission('student:edit') + async deleteExamScore(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.deleteExamScore(+id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '删除考试成绩', + targetId: +id, + targetType: 'exam_score', + ipAddress, + userAgent, + }); + return result; + } + + @Post(':studentId/learning-records') + @RequirePermission('student:edit') + async addLearningRecord( + @Param('studentId') studentId: string, + @Body() dto: CreateLearningRecordDto, + @Request() req: AuthenticatedRequest, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.addLearningRecord(+studentId, dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '添加学习记录', + targetId: result.id, + targetType: 'learning_record', + detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`, + ipAddress, + userAgent, + }); + return result; + } + + @Put('learning-records/:id') + @RequirePermission('student:edit') + async updateLearningRecord( + @Param('id') id: string, + @Body() dto: Partial, + @Request() req: AuthenticatedRequest, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.updateLearningRecord(+id, dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '编辑学习记录', + targetId: +id, + targetType: 'learning_record', + detail: JSON.stringify(dto), + ipAddress, + userAgent, + }); + return result; + } + + @Delete('learning-records/:id') + @RequirePermission('student:edit') + async deleteLearningRecord(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.deleteLearningRecord(+id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '删除学习记录', + targetId: +id, + targetType: 'learning_record', + ipAddress, + userAgent, + }); + return result; + } + + @Put(':studentId/result') + @RequirePermission('student:edit') + async upsertResult( + @Param('studentId') studentId: string, + @Body() dto: UpsertResultDto, + @Request() req: AuthenticatedRequest, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.upsertResult(+studentId, dto); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '更新录取结果', + targetId: +studentId, + targetType: 'result_archive', + detail: JSON.stringify(dto), + ipAddress, + userAgent, + }); + return result; + } + + @Post(':studentId/attachments') + @RequirePermission('student:edit') + @UseInterceptors(FileInterceptor('file')) + async uploadAttachment( + @Param('studentId') studentId: string, + @UploadedFile() file: Express.Multer.File, + @Body('category') category: string, + @Request() req: AuthenticatedRequest, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.addAttachment(+studentId, file, category || 'other'); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '上传附件', + targetId: result.id, + targetType: 'archive_attachment', + detail: `${file.originalname} (${category || 'other'})`, + ipAddress, + userAgent, + }); + return result; + } + + @Delete('attachments/:id') + @RequirePermission('student:edit') + async deleteAttachment(@Param('id') id: string, @Request() req: AuthenticatedRequest) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.deleteAttachment(+id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '删除附件', + targetId: +id, + targetType: 'archive_attachment', + ipAddress, + userAgent, + }); + return result; + } + + @Get(':studentId/report') + @RequirePermission('student:view') + async generateReport(@Param('studentId') studentId: string) { + return { + studentId: +studentId, + message: '学生档案报告功能开发中', + generatedAt: new Date().toISOString(), + }; + } +} diff --git a/apps/server/src/archive/archive.module.ts b/apps/server/src/archive/archive.module.ts new file mode 100644 index 0000000..d78ffd6 --- /dev/null +++ b/apps/server/src/archive/archive.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +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 { CommonModule } from '../common/common.module'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { ArchiveService } from './archive.service'; +import { ArchiveController } from './archive.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Student, + StudentProfile, + StudentEnrollment, + ExamScore, + LearningRecord, + ResultArchive, + ArchiveAttachment, + ]), + CommonModule, + NotificationsModule, + ], + controllers: [ArchiveController], + providers: [ArchiveService], + exports: [ArchiveService], +}) +export class ArchiveModule {} diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts new file mode 100644 index 0000000..88def4e --- /dev/null +++ b/apps/server/src/archive/archive.service.ts @@ -0,0 +1,209 @@ +import { 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 { CampusScope } from '../common/campus-scope'; +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 { + UpsertProfileDto, + CreateEnrollmentDto, + CreateExamScoreDto, + CreateLearningRecordDto, + 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, + private readonly scope: CampusScope, + private readonly notificationsService: NotificationsService, + ) {} + + async getProfile(studentId: number) { + const student = await this.studentRepo.findOne({ where: { id: studentId } }); + if (!student) throw new NotFoundException('学生不存在'); + + const [ + profileRaw, + enrollments, + examScores, + learningRecords, + resultArchive, + attachments, + ] = await Promise.all([ + this.profileRepo.findOne({ where: await this.scope.filter({ studentId }) }), + this.enrollmentRepo.find({ + where: await this.scope.filter({ studentId }), + order: { createdAt: 'DESC' }, + }), + this.examScoreRepo.find({ + where: await this.scope.filter({ studentId }), + order: { examDate: 'DESC' }, + }), + this.learningRecordRepo.find({ + where: await this.scope.filter({ studentId }), + order: { recordDate: 'DESC' }, + }), + this.resultRepo.findOne({ where: await this.scope.filter({ studentId }) }), + this.attachmentRepo.find({ + where: await this.scope.filter({ studentId }), + order: { createdAt: 'DESC' }, + }), + ]); + + return { + student, + profile: profileRaw, + enrollments, + examScores, + learningRecords, + resultArchive, + attachments, + }; + } + + 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: Partial) { + 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('报名记录不存在'); + await this.enrollmentRepo.remove(entity); + return { message: '已删除' }; + } + + async addExamScore(studentId: number, dto: CreateExamScoreDto) { + const student = await this.studentRepo.findOne({ where: { id: studentId } }); + if (!student) throw new NotFoundException('学生不存在'); + + const entity = this.examScoreRepo.create({ ...dto, studentId }); + return this.examScoreRepo.save(entity); + } + + async updateExamScore(id: number, dto: Partial) { + const entity = await this.examScoreRepo.findOne({ where: { id } }); + if (!entity) throw new NotFoundException('考试成绩不存在'); + 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('考试成绩不存在'); + await this.examScoreRepo.remove(entity); + 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: Partial) { + 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('学习记录不存在'); + await this.learningRecordRepo.remove(entity); + 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) { + const student = await this.studentRepo.findOne({ where: { id: studentId } }); + if (!student) throw new NotFoundException('学生不存在'); + + const uploadDir = path.join(process.cwd(), 'uploads', 'archive'); + 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: `uploads/archive/${filename}`, + fileSize: file.size, + mimeType: file.mimetype, + }); + return this.attachmentRepo.save(entity); + } + + async deleteAttachment(id: number) { + const entity = await this.attachmentRepo.findOne({ where: { id } }); + if (!entity) throw new NotFoundException('附件不存在'); + + const absPath = path.join(process.cwd(), entity.filePath); + if (fs.existsSync(absPath)) { + fs.unlinkSync(absPath); + } + + await this.attachmentRepo.remove(entity); + return { message: '已删除' }; + } +} diff --git a/apps/server/src/archive/dto/archive.dto.ts b/apps/server/src/archive/dto/archive.dto.ts new file mode 100644 index 0000000..404cb77 --- /dev/null +++ b/apps/server/src/archive/dto/archive.dto.ts @@ -0,0 +1,49 @@ +import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator'; + +export class UpsertProfileDto { + @IsOptional() @IsString() targetCollege?: string; + @IsOptional() @IsString() targetMajor?: string; + @IsOptional() @IsString() subjectDirection?: string; + @IsOptional() @IsString() grade?: string; + @IsOptional() @IsString() campusLocation?: string; + @IsOptional() @IsDateString() profileDate?: string; + @IsOptional() @IsString() notes?: string; +} + +export class CreateEnrollmentDto { + @IsString() courseCategory: string; + @IsString() classType: string; + @IsOptional() @IsString() className?: string; + @IsOptional() @IsString() headTeacher?: string; + @IsOptional() @IsString() subjectTeacher?: string; + @IsOptional() @IsDateString() startDate?: string; + @IsOptional() @IsDateString() endDate?: string; + @IsOptional() @IsString() status?: string; +} + +export class CreateExamScoreDto { + @IsString() examType: string; + @IsOptional() @IsString() examName?: string; + @IsString() subject: string; + @IsNumber() score: number; + @IsOptional() @IsNumber() classAvg?: number; + @IsOptional() @IsNumber() rank?: number; + @IsOptional() @IsDateString() examDate?: string; + @IsOptional() @IsNumber() enrollmentId?: number; +} + +export class CreateLearningRecordDto { + @IsDateString() recordDate: string; + @IsString() recordType: string; + @IsString() content: string; + @IsOptional() @IsString() followUpMethod?: string; + @IsOptional() @IsString() nextStep?: string; +} + +export class UpsertResultDto { + @IsOptional() @IsNumber() cultureFinalScore?: number; + @IsOptional() @IsNumber() professionalFinalScore?: number; + @IsOptional() @IsString() admissionStatus?: string; + @IsOptional() @IsString() admittedCollege?: string; + @IsOptional() @IsString() admittedMajor?: string; +}