feat: add student archive module with full CRUD APIs

This commit is contained in:
2026-07-06 15:46:57 +08:00
parent 9eec7c34be
commit b6990dd11b
5 changed files with 655 additions and 2 deletions

View File

@@ -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 },

View File

@@ -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<CreateEnrollmentDto>,
@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<CreateExamScoreDto>,
@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<CreateLearningRecordDto>,
@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(),
};
}
}

View File

@@ -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 {}

View File

@@ -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<Student>,
@InjectRepository(StudentProfile) private profileRepo: Repository<StudentProfile>,
@InjectRepository(StudentEnrollment) private enrollmentRepo: Repository<StudentEnrollment>,
@InjectRepository(ExamScore) private examScoreRepo: Repository<ExamScore>,
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
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<CreateEnrollmentDto>) {
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<CreateExamScoreDto>) {
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<CreateLearningRecordDto>) {
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: '已删除' };
}
}

View File

@@ -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;
}