Files
gongxue-base/apps/server/src/attendance/attendance-import.controller.ts

148 lines
5.7 KiB
TypeScript

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<SseEvent> {
const userId = req.user.id;
return new Observable<SseEvent>((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();
});
}
}