148 lines
5.7 KiB
TypeScript
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();
|
|
});
|
|
}
|
|
}
|