import { Controller, Get, Post, Put, Delete, Sse, Body, Param, Query, UseGuards, Request, Res, BadRequestException, ForbiddenException, ParseIntPipe, } from '@nestjs/common'; import { Observable, filter } from 'rxjs'; import type { Request as ExpressRequest, Response } from 'express'; import { AttendanceService } from './attendance.service'; import { AttendanceImportService } from './attendance-import.service'; import { DingTalkImportDto } from './dto/dingtalk-import.dto'; import { BatchCreateAttendanceDto, AttendanceSummaryQueryDto, AttendanceCalendarQueryDto, QueryAttendanceRecordsDto, QueryDingRawDto, MatchDingRecordDto, AttendanceReportQueryDto, AttendanceAlertsQueryDto, UpdateAttendanceRecordDto, GenerateFromSchedulesDto, LessonAttendanceQueryDto, StartLessonAttendanceDto, } from './dto/attendance.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 * as ExcelJS from 'exceljs'; import { AuthorizationService, CaslAction, SubjectName } from '../authorization'; import type { AuthenticatedUser } from '../authorization'; /** SSE event shape for @Sse() decorator */ interface SseEvent { data: string | Record; id?: string; type?: string; retry?: number; } /** Minimal request user shape for type safety */ interface RequestUser { id: number; username: string; permissions: string[]; isSuperAdmin: boolean; roles: string[]; } @UseGuards(JwtAuthGuard) @Controller() export class AttendanceController { constructor( private readonly service: AttendanceService, private readonly importService: AttendanceImportService, private readonly logService: OperationLogsService, private readonly authz: AuthorizationService, ) {} private getTodayDateOnly(): string { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } private canManageAllAttendance(req: { user: RequestUser }): boolean { return ( this.authz.can(req, CaslAction.Manage, SubjectName.Attendance) || // Legacy: class:edit grants broad attendance access for teacher scoping this.authz.can(req, CaslAction.Update, SubjectName.Class) ); } private getAccessibleClassIds(req: { user: RequestUser }) { return this.service.getAccessibleClassIds(req.user.id, this.canManageAllAttendance(req)); } private assertClassAccess(req: { user: RequestUser }, classId: number) { return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req)); } @Get('attendance-lessons/schedules/:scheduleId') @RequirePermission('attendance:view') async getLessonAttendance( @Param('scheduleId', ParseIntPipe) scheduleId: number, @Query() query: LessonAttendanceQueryDto, @Request() req: { user: RequestUser }, ) { const result = await this.service.getLessonAttendance(scheduleId, query.date); await this.assertClassAccess(req, result.schedule.classId!); return result; } @Post('attendance-lessons/schedules/:scheduleId/pull') @RequirePermission('attendance:create') async pullLessonAttendance( @Param('scheduleId', ParseIntPipe) scheduleId: number, @Body() dto: StartLessonAttendanceDto, @Request() req: { user: RequestUser }, ) { const schedule = await this.service.getLessonAttendance(scheduleId, dto.date); await this.assertClassAccess(req, schedule.schedule.classId!); const importClassIds = await this.service.getTeacherClassDingUserIds( req.user.id, schedule.schedule.classId!, this.canManageAllAttendance(req), ); const importRange = this.service.getLessonAttendanceImportDateRange( schedule.schedule, dto.date, ); const importResult = await this.importService.importFromDingTalk({ ...importRange, userIds: importClassIds, autoMatch: true, userId: req.user.id, }); const result = await this.service.createLessonAttendanceFromDingTalk( scheduleId, dto.date, req.user.id, ); await this.logService.log({ userId: req.user.id, username: req.user.username, module: '考勤管理', action: schedule.session ? '查看已拉取课程考勤' : '拉取钉钉课程考勤', targetId: result.session.id, targetType: 'attendanceSession', detail: `排课${scheduleId} 日期${dto.date},钉钉新增${importResult.imported}条,匹配${importResult.matched}条`, }); return result; } @Post('attendance-lessons/:sessionId/complete') @RequirePermission('attendance:create') async completeLessonAttendance( @Param('sessionId', ParseIntPipe) sessionId: number, @Request() req: { user: RequestUser }, ) { const session = await this.service.findAttendanceSession(sessionId); await this.assertClassAccess(req, session.classId); const result = await this.service.completeLessonAttendance(sessionId, req.user.id); await this.logService.log({ userId: req.user.id, username: req.user.username, module: '考勤管理', action: '完成课程点名', targetId: sessionId, targetType: 'attendanceSession', detail: `班级${session.classId} 日期${session.lessonDate}`, }); return result; } // ── Batch create attendance records ── @Post('attendance-records/batch') @RequirePermission('attendance:create') async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const canManageAll = this.canManageAllAttendance(req); if (!canManageAll && dto.records.some((record) => record.classId == null)) { throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级'); } const classIds = [ ...new Set( dto.records.map((record) => record.classId).filter((id): id is number => id != null), ), ]; for (const classId of classIds) { await this.service.assertClassAccess(req.user.id, classId, canManageAll); } const result = await this.service.batchCreate(dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '考勤管理', action: '批量录入考勤', detail: `共 ${result.count} 条`, ipAddress, userAgent, }); return result; } // ── Generate attendance records from schedules (with optional date range) ── @Post('attendance-records/generate-from-schedules') @RequirePermission('attendance:create') async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); await this.assertClassAccess(req, dto.classId); const result = await this.service.generateFromSchedules(dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '考勤管理', action: '按课表生成考勤', detail: `班级 ${dto.classId}, 共 ${result.count} 条`, ipAddress, userAgent, }); return result; } // ── Export attendance records ── @Get('attendance-records/export') @RequirePermission('attendance:export') async exportRecords( @Query() query: QueryAttendanceRecordsDto, @Res() res: Response, @Request() req: { user: RequestUser }, ) { if (query.classId) await this.assertClassAccess(req, query.classId); const classIds = await this.getAccessibleClassIds(req); const records = await this.service.findAllForExport(query, classIds); const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('考勤统计报表'); ws.columns = [ { header: '姓名', key: 'studentName', width: 15 }, { header: '班级', key: 'className', width: 20 }, { header: '日期', key: 'attendanceDate', width: 15 }, { header: '时段', key: 'session', width: 15 }, { header: '状态', key: 'status', width: 10 }, { header: '来源', key: 'source', width: 10 }, { header: '备注', key: 'remark', width: 30 }, { header: '打卡时间', key: 'createdAt', width: 20 }, ]; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; for (const record of records) { ws.addRow({ studentName: record.student?.name || '', className: record.class?.name || '', attendanceDate: record.attendanceDate || '', session: record.session || '', status: record.status || '', source: record.source || '', remark: record.remark || '', createdAt: record.createdAt ? record.createdAt.toISOString().replace('T', ' ').substring(0, 19) : '', }); } const dateRange = [query.dateFrom, query.dateTo].filter(Boolean).join('-') || '全部'; res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); res.setHeader( 'Content-Disposition', `attachment; filename=${encodeURIComponent(`考勤统计报表-${dateRange}`)}.xlsx`, ); await workbook.xlsx.write(res); res.end(); } // ── List attendance records with filters ── @Get('attendance-records') @RequirePermission('attendance:view') async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) { if (query.classId) await this.assertClassAccess(req, query.classId); return this.service.findAll(query, await this.getAccessibleClassIds(req)); } // ── Update a single attendance record ── @Put('attendance-records/:id') @RequirePermission('attendance:edit', 'attendance:self-edit') async update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceRecordDto, @Request() req: any, ) { const { ipAddress, userAgent } = extractRequestInfo(req); const existing = await this.service.findAttendanceRecord(id); if (existing.classId == null && !this.canManageAllAttendance(req)) { throw new ForbiddenException('无权修改未关联班级的考勤记录'); } if (existing.classId != null) await this.assertClassAccess(req, existing.classId); 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: 'attendanceRecord', detail: `状态=${result.status}, 备注=${result.remark || ''}`, ipAddress, userAgent, }); return result; } // ── Delete a single attendance record ── @Delete('attendance-records/:id') @RequirePermission('attendance:edit', 'attendance:self-edit') async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const existing = await this.service.findAttendanceRecord(id); if (existing.classId == null && !this.canManageAllAttendance(req)) { throw new ForbiddenException('无权删除未关联班级的考勤记录'); } if (existing.classId != null) await this.assertClassAccess(req, existing.classId); const result = await this.service.remove(id); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '考勤管理', action: '归档考勤记录', targetId: id, targetType: 'attendanceRecord', detail: `归档考勤记录 ${id}`, ipAddress, userAgent, }); return result; } // ── Get distinct classes with attendance records ── @Get('attendance-records/classes') @RequirePermission('attendance:view') async getClasses(@Request() req: { user: RequestUser }) { return this.service.getClasses(await this.getAccessibleClassIds(req)); } // ── Attendance summary ── @Get('attendance-records/summary') @RequirePermission('attendance:view') async getSummary( @Query() query: AttendanceSummaryQueryDto, @Request() req: { user: RequestUser }, ) { if (query.classId) await this.assertClassAccess(req, query.classId); return this.service.getSummary(query, await this.getAccessibleClassIds(req)); } // ── Attendance calendar ── @Get('attendance-records/calendar') @RequirePermission('attendance:view') async getCalendar( @Query() query: AttendanceCalendarQueryDto, @Request() req: { user: RequestUser }, ) { await this.assertClassAccess(req, query.classId); return this.service.getCalendar(query); } // ── DingAttendance raw records ── @Get('ding-attendance-raw') @RequirePermission('attendance:view') async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) { if (query.classId) await this.assertClassAccess(req, query.classId); return this.service.getDingRaw(query, await this.getAccessibleClassIds(req)); } // ── Match a dingtalk record to a student ── @Post('ding-attendance-raw/:id/match') @RequirePermission('attendance:edit') async matchDingRecord( @Param('id', ParseIntPipe) id: number, @Body() dto: MatchDingRecordDto, @Request() req: any, ) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.matchDingRecord(id, dto); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '考勤管理', action: '匹配考勤记录', targetId: id, targetType: 'dingAttendanceRaw', detail: `匹配到学生 ${dto.studentId}`, ipAddress, userAgent, }); return result; } // ── Attendance class-based report export ── @Get('attendance-records/report') @RequirePermission('attendance:export') async exportReport( @Query() query: AttendanceReportQueryDto, @Res() res: Response, @Request() req: any, ) { if (query.classId) await this.assertClassAccess(req, query.classId); const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req)); const workbook = new ExcelJS.Workbook(); const ws = workbook.addWorksheet('考勤统计报表'); ws.columns = [ { header: '班级名称', key: 'className', width: 30 }, { header: '总记录数', key: 'total', width: 12 }, { header: '出勤', key: 'present', width: 10 }, { header: '出勤率', key: 'presentRate', width: 10 }, { header: '缺勤', key: 'absent', width: 10 }, { header: '缺勤率', key: 'absentRate', width: 10 }, { header: '迟到', key: 'late', width: 10 }, { header: '迟到率', key: 'lateRate', width: 10 }, { header: '请假', key: 'leave', width: 10 }, { header: '请假率', key: 'leaveRate', width: 10 }, ]; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; for (const row of reportData) { ws.addRow({ className: row.className, total: row.total, present: row.present, presentRate: `${row.presentRate}%`, absent: row.absent, absentRate: `${row.absentRate}%`, late: row.late, lateRate: `${row.lateRate}%`, leave: row.leave, leaveRate: `${row.leaveRate}%`, }); } // Audit log const { ipAddress, userAgent } = extractRequestInfo(req); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '考勤管理', action: '导出考勤报表', detail: `classId=${query.classId || '全部'} ${query.dateFrom || ''}~${query.dateTo || ''}`, ipAddress, userAgent, }); res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); res.setHeader('Content-Disposition', 'attachment; filename=attendance-report.xlsx'); await workbook.xlsx.write(res); res.end(); } // ── Abnormal attendance alerts ── @Get('attendance-records/alerts') @RequirePermission('attendance:view') async getAlerts( @Request() req: { user: RequestUser }, @Query() query: AttendanceAlertsQueryDto, ) { return this.service.getAlerts( query.days ?? 14, query.threshold ?? 3, await this.getAccessibleClassIds(req), ); } @Post('ding-attendance-raw/auto-match') @RequirePermission('attendance:edit') async autoMatch() { return this.service.autoMatchDingRecords(); } // ═══════════════════════════════════════════════════════════════ // DingTalk attendance import with SSE streaming progress // ═══════════════════════════════════════════════════════════════ @Get('attendance-records/import/dingtalk/classes') @RequirePermission('attendance:create') getDingTalkImportClasses(@Request() req: { user: RequestUser }) { return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req)); } /** * Trigger DingTalk attendance import. * Mirrors `dws attendance check result` pipeline: * fetch → parse → deduplicate → save → auto-match. */ @Post('attendance-records/import/dingtalk') @RequirePermission('attendance:create') async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) { const { ipAddress, userAgent } = extractRequestInfo(req); const canManageAll = this.canManageAllAttendance(req); let userIds: string[]; if (dto.users) { if (!canManageAll) { throw new ForbiddenException('仅管理员可指定钉钉用户范围'); } userIds = dto.users .split(',') .map((value) => value.trim()) .filter(Boolean); } else { if (!dto.classId) { throw new BadRequestException('请选择要拉取考勤的班级'); } userIds = await this.service.getTeacherClassDingUserIds( req.user.id, dto.classId, canManageAll, ); } const startDate = dto.start ?? this.getTodayDateOnly(); const endDate = dto.end ?? startDate; const result = await this.importService.importFromDingTalk({ startDate, endDate, userIds, autoMatch: true, userId: req.user.id, }); await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '考勤管理', action: '钉钉考勤导入', detail: `${startDate}~${endDate}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`, ipAddress, userAgent, }); return result; } /** * SSE stream for live import progress. * Connect before triggering the import to receive real-time progress events. * * NOTE: @RequirePermission works with @Sse() in NestJS because guards * execute in the standard request pipeline before the SSE handler is invoked. * If this ever breaks after a NestJS upgrade, verify guard execution order. */ @Sse('attendance-records/import/dingtalk/stream') @RequirePermission('attendance:view') importProgressStream(@Request() req: { user: RequestUser }): Observable { const userId = req.user.id; return new Observable((subscriber) => { const subscription = this.importService.progress$ .pipe( filter((event) => event.userId === userId), ) .subscribe({ next: (event) => { subscriber.next({ data: JSON.stringify(event) }); if (event.phase === 'complete' || event.phase === 'error') { subscriber.complete(); } }, error: (err: unknown) => subscriber.error(err), }); return () => subscription.unsubscribe(); }); } }