import { Controller, Get, Post, Sse, Body, Param, Query, Request, BadRequestException, ForbiddenException, ParseIntPipe } from '@nestjs/common'; import { Observable, filter } from 'rxjs'; import { AttendanceControllerBase, RequestUser, SseEvent } from './attendance.controller-base'; import { AttendanceService } from './attendance.service'; import { AttendanceImportService } from './attendance-import.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AuthorizationService } from '../authorization'; import { logAudit } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { DingTalkImportDto } from './dto/dingtalk-import.dto'; import { QueryDingRawDto, MatchDingRecordDto } from './dto/attendance.dto'; @Controller() export class AttendanceImportController extends AttendanceControllerBase { constructor( service: AttendanceService, importService: AttendanceImportService, logService: OperationLogsService, authz: AuthorizationService, ) { super(service, importService, logService, authz); } @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 result = await this.service.matchDingRecord(id, dto); await logAudit(this.logService, req, { module: '考勤管理', action: '匹配考勤记录', targetId: id, targetType: 'dingAttendanceRaw', detail: `匹配到学生 ${dto.studentId}`, }); return result; } // ── Attendance class-based report export ── @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, dto.start, ); } 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(); }); } }