import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Query, Request, Res, UseInterceptors, UploadedFile, Inject, ParseIntPipe, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Organization } from '../entities/organization.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Response } from 'express'; import { StudentsService } from './students.service'; import { CreateStudentDto, QueryStudentDto, UpdateStudentDto } from './dto/student.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'; import { AuthorizationService, CaslAction, SubjectName } from '../authorization'; import type { AuthenticatedUser } from '../authorization'; import * as ExcelJS from 'exceljs'; import { createStudentImportTemplateWorkbook, parseStudentImportWorkbook, STUDENT_EXPORT_COLUMNS, } from './student-import'; interface AuthenticatedRequest { user: AuthenticatedUser; } @UseGuards(JwtAuthGuard) @Controller('students') export class StudentsController { constructor( private service: StudentsService, private logService: OperationLogsService, @InjectRepository(Organization) private organizationRepo: Repository, private authz: AuthorizationService, ) {} private canManageAllStudents(req: AuthenticatedRequest): boolean { return ( this.authz.can(req, CaslAction.Manage, SubjectName.Student) || // Legacy: class:edit grants broad student access for teacher scoping this.authz.can(req, CaslAction.Update, SubjectName.Class) ); } @Get('basic-lookups') @RequirePermission('student:basic-view', 'student:view') getBasicLookups() { return this.service.getBasicLookups(); } @Get('filter-lookups') @RequirePermission('student:view') async getFilterLookups(@Request() req: AuthenticatedRequest) { const classIds = await this.service.getAccessibleClassIds( req.user.id, this.canManageAllStudents(req), ); return this.service.getFilterLookups(classIds); } @Get() @RequirePermission('student:view') async findAll( @Query() query: QueryStudentDto, @Request() req: AuthenticatedRequest, ) { const classIds = await this.service.getAccessibleClassIds( req.user.id, this.canManageAllStudents(req), ); return this.service.findAll( query, classIds, ); } @Get('export') @RequirePermission('student:export') async exportExcel( @Query() query: QueryStudentDto, @Res() res?: Response, @Request() req?: any, ) { const classIds = await this.service.getAccessibleClassIds( req.user.id, this.canManageAllStudents(req), ); const students = await this.service.findAll(query, classIds); const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('学生名单'); ws.columns = STUDENT_EXPORT_COLUMNS; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; const { profiles, results } = await this.service.getArchiveExportMaps( students.map((student) => student.id), ); for (const s of students) { const profile = profiles.get(s.id); const result = results.get(s.id); ws.addRow({ phone: s.phone || '', name: s.name, studentNo: s.studentNo || '', gender: s.gender || '', idNumber: s.idNumber || '', ethnicity: s.ethnicity || '', emergencyContact: s.emergencyContact || '', emergencyPhone: s.emergencyPhone || '', organization: s.organization?.name || '', supervisor: s.supervisor || '', targetCollege: profile?.targetCollege || '', targetMajor: profile?.targetMajor || '', subjectDirection: profile?.subjectDirection || '', grade: profile?.grade || '', profileDate: profile?.profileDate || '', notes: profile?.notes || '', cultureFinalScore: result?.cultureFinalScore ?? '', professionalFinalScore: result?.professionalFinalScore ?? '', admissionStatus: result?.admissionStatus || '', admittedCollege: result?.admittedCollege || '', admittedMajor: result?.admittedMajor || '', }); } const { ipAddress, userAgent } = extractRequestInfo(req); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '导出学生', detail: `导出 ${students.length} 名学生`, ipAddress, userAgent, }); res!.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); res!.setHeader('Content-Disposition', 'attachment; filename=students.xlsx'); await workbook.xlsx.write(res!); res!.end(); } @Get('template') @RequirePermission('student:view') async downloadTemplate(@Res() res: Response) { const workbook = createStudentImportTemplateWorkbook(); res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); res.setHeader('Content-Disposition', 'attachment; filename=student_template.xlsx'); await workbook.xlsx.write(res); res.end(); } @Get(':id') @RequirePermission('student:view') findOne(@Param('id', ParseIntPipe) id: number) { return this.service.findOne(id); } @Post() @RequirePermission('student:create') async create(@Body() dto: CreateStudentDto, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`, ipAddress, userAgent, }); return result; } @Put(':id') @RequirePermission('student:edit') async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '编辑学生', targetId: id, targetType: 'student', detail: JSON.stringify(dto), ipAddress, userAgent, }); return result; } @Delete(':id') @RequirePermission('student:delete') async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '归档学生', targetId: id, targetType: 'student', ipAddress, userAgent, }); return result; } @Post('batch-delete') @RequirePermission('student:delete') async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.batchRemove(body.ids || []); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`, ipAddress, userAgent, }); return result; } @Put(':id/restore') @RequirePermission('student:edit') async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.restore(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '恢复学生', targetId: id, targetType: 'student', ipAddress, userAgent, }); return result; } @Post('import') @RequirePermission('student:import') @UseInterceptors(FileInterceptor('file')) async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as any); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { if (row.organization) { const organization = await this.organizationRepo.findOne({ where: { name: row.organization }, }); if (organization) { row.organizationId = organization.id; } } } const result = await this.service.batchImport(importData); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '导入学生', detail: result.message, ipAddress, userAgent, }); return result; } @Post('import-match') @RequirePermission('student:import') @UseInterceptors(FileInterceptor('file')) async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { if (row.organization) { const organization = await this.organizationRepo.findOne({ where: { name: row.organization }, }); if (organization) row.organizationId = organization.id; } } const result = await this.service.matchImport(importData); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生管理', action: '更新已有学生资料', detail: result.message, ipAddress, userAgent, }); return result; } @Get(':id/compare-classes') @RequirePermission('student:view') compareClasses(@Param('id', ParseIntPipe) id: number) { return this.service.compareClasses(id); } }