Files
gongxue-base/apps/server/src/students/students.controller.ts

307 lines
11 KiB
TypeScript

import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
Query,
Request,
Res,
UseInterceptors,
UploadedFile,
ParseIntPipe,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Organization } from '../entities/organization.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 { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import {
AuthorizationService,
CaslAction,
SubjectName,
type AuthenticatedUser,
} from '../authorization';
import * as ExcelJS from 'exceljs';
import {
createStudentImportTemplateWorkbook,
parseStudentImportWorkbook,
STUDENT_EXPORT_COLUMNS,
} from './student-import';
import { BatchIdsDto } from '../common/batch-ids.dto';
interface AuthenticatedRequest {
user: AuthenticatedUser;
}
@UseGuards(JwtAuthGuard)
@Controller('students')
export class StudentsController {
constructor(
private service: StudentsService,
private logService: OperationLogsService,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
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 || '',
collegeSchool: profile?.collegeSchool || '',
collegeMajor: profile?.collegeMajor || '',
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 || '',
});
}
await logAudit(this.logService, req, {
module: '学生管理', action: '导出学生', detail: `导出 ${students.length} 名学生`,
});
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 result = await this.service.create(dto);
await logAudit(this.logService, req, {
module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,
});
return result;
}
@Put('batch-restore')
@RequirePermission('student:edit')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) {
const result = await this.service.batchRestore(dto.ids);
await logAudit(this.logService, req, {
module: '学生管理', action: '批量恢复学生', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@Put(':id')
@RequirePermission('student:edit')
async update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateStudentDto,
@Request() req: any,
) {
const result = await this.service.update(id, dto);
await logAudit(this.logService, req, {
module: '学生管理', action: '编辑学生', targetId: id, targetType: 'student', detail: JSON.stringify(dto),
});
return result;
}
@Delete(':id')
@RequirePermission('student:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.remove(id);
await logAudit(this.logService, req, {
module: '学生管理', action: '归档学生', targetId: id, targetType: 'student',
});
return result;
}
@Post('batch-delete')
@RequirePermission('student:delete')
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchRemove(body.ids || []);
await logAudit(this.logService, req, {
module: '学生管理', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('student:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purge(id);
await logAudit(this.logService, req, {
module: '学生管理', action: '永久删除学生', targetId: id, targetType: 'student', detail: '物理删除,不可恢复',
});
return result;
}
@Post('batch-permanent-delete')
@RequirePermission('student:purge')
async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchPurge(body.ids || []);
await logAudit(this.logService, req, {
module: '学生管理', action: '批量永久删除学生', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Put(':id/restore')
@RequirePermission('student:edit')
async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.restore(id);
await logAudit(this.logService, req, {
module: '学生管理', action: '恢复学生', targetId: id, targetType: 'student',
});
return result;
}
@Post('import')
@RequirePermission('student:import')
@UseInterceptors(FileInterceptor('file'))
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
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.batchImport(importData);
await logAudit(this.logService, req, {
module: '学生管理', action: '导入学生', detail: result.message,
});
return result;
}
@Post('import-match')
@RequirePermission('student:import')
@UseInterceptors(FileInterceptor('file'))
async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
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 logAudit(this.logService, req, {
module: '学生管理', action: '更新已有学生资料', detail: result.message,
});
return result;
}
@Get(':id/compare-classes')
@RequirePermission('student:view')
compareClasses(@Param('id', ParseIntPipe) id: number) {
return this.service.compareClasses(id);
}
}