feat: 考勤模块重构与钉钉考勤同步
This commit is contained in:
147
apps/server/src/attendance/attendance-import.controller.ts
Normal file
147
apps/server/src/attendance/attendance-import.controller.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user