forked from wangziqi/gongxue-base
fix permissions and teacher attendance workflows
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { Request as ExpressRequest, Response } from 'express';
|
||||
@@ -34,7 +36,6 @@ import { extractRequestInfo } from '../common/request-utils';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
|
||||
/** SSE event shape for @Sse() decorator */
|
||||
interface SseEvent {
|
||||
data: string | Record<string, unknown>;
|
||||
@@ -46,7 +47,8 @@ interface SseEvent {
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -58,13 +60,30 @@ export class AttendanceController {
|
||||
private readonly logService: OperationLogsService,
|
||||
) {}
|
||||
|
||||
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(user: RequestUser): boolean {
|
||||
return user.isSuperAdmin === true || user.permissions?.includes('class:edit') === true;
|
||||
}
|
||||
|
||||
private getAccessibleClassIds(user: RequestUser) {
|
||||
return this.service.getAccessibleClassIds(user.id, this.canManageAllAttendance(user));
|
||||
}
|
||||
|
||||
private assertClassAccess(user: RequestUser, classId: number) {
|
||||
return this.service.assertClassAccess(user.id, classId, this.canManageAllAttendance(user));
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
@Post('attendance-records/batch')
|
||||
@RequirePermission('attendance:create')
|
||||
async batchCreate(
|
||||
@Body() dto: BatchCreateAttendanceDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.batchCreate(dto);
|
||||
await this.logService.log({
|
||||
@@ -82,10 +101,7 @@ export class AttendanceController {
|
||||
// ── 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,
|
||||
) {
|
||||
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.generateFromSchedules(dto);
|
||||
await this.logService.log({
|
||||
@@ -100,15 +116,17 @@ export class AttendanceController {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// ── Export attendance records ──
|
||||
@Get('attendance-records/export')
|
||||
@RequirePermission('attendance:export')
|
||||
async exportRecords(
|
||||
@Query() query: QueryAttendanceRecordsDto,
|
||||
@Res() res: Response,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const records = await this.service.findAllForExport(query);
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
const classIds = await this.getAccessibleClassIds(req.user);
|
||||
const records = await this.service.findAllForExport(query, classIds);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('考勤统计报表');
|
||||
@@ -140,9 +158,7 @@ export class AttendanceController {
|
||||
});
|
||||
}
|
||||
|
||||
const dateRange = [query.dateFrom, query.dateTo]
|
||||
.filter(Boolean)
|
||||
.join('-') || '全部';
|
||||
const dateRange = [query.dateFrom, query.dateTo].filter(Boolean).join('-') || '全部';
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
@@ -159,8 +175,9 @@ export class AttendanceController {
|
||||
// ── List attendance records with filters ──
|
||||
@Get('attendance-records')
|
||||
@RequirePermission('attendance:view')
|
||||
findAll(@Query() query: QueryAttendanceRecordsDto) {
|
||||
return this.service.findAll(query);
|
||||
async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.findAll(query, await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Update a single attendance record ──
|
||||
@@ -190,10 +207,7 @@ export class AttendanceController {
|
||||
// ── Delete a single attendance record ──
|
||||
@Delete('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit')
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
@@ -213,29 +227,38 @@ export class AttendanceController {
|
||||
// ── Get distinct classes with attendance records ──
|
||||
@Get('attendance-records/classes')
|
||||
@RequirePermission('attendance:view')
|
||||
getClasses() {
|
||||
return this.service.getClasses();
|
||||
async getClasses(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getClasses(await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Attendance summary ──
|
||||
@Get('attendance-records/summary')
|
||||
@RequirePermission('attendance:view')
|
||||
getSummary(@Query() query: AttendanceSummaryQueryDto) {
|
||||
return this.service.getSummary(query);
|
||||
async getSummary(
|
||||
@Query() query: AttendanceSummaryQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getSummary(query, await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Attendance calendar ──
|
||||
@Get('attendance-records/calendar')
|
||||
@RequirePermission('attendance:view')
|
||||
getCalendar(@Query() query: AttendanceCalendarQueryDto) {
|
||||
async getCalendar(
|
||||
@Query() query: AttendanceCalendarQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getCalendar(query);
|
||||
}
|
||||
|
||||
// ── DingAttendance raw records ──
|
||||
@Get('ding-attendance-raw')
|
||||
@RequirePermission('attendance:view')
|
||||
getDingRaw(@Query() query: QueryDingRawDto) {
|
||||
return this.service.getDingRaw(query);
|
||||
async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Match a dingtalk record to a student ──
|
||||
@@ -270,7 +293,11 @@ export class AttendanceController {
|
||||
@Res() res: Response,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const reportData = await this.service.getReport(query);
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
const reportData = await this.service.getReport(
|
||||
query,
|
||||
await this.getAccessibleClassIds(req.user),
|
||||
);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('考勤统计报表');
|
||||
@@ -316,7 +343,10 @@ export class AttendanceController {
|
||||
userAgent,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
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();
|
||||
@@ -325,13 +355,15 @@ export class AttendanceController {
|
||||
// ── Abnormal attendance alerts ──
|
||||
@Get('attendance-records/alerts')
|
||||
@RequirePermission('attendance:view')
|
||||
getAlerts(
|
||||
async getAlerts(
|
||||
@Request() req: { user: RequestUser },
|
||||
@Query('days') days?: string,
|
||||
@Query('threshold') threshold?: string,
|
||||
) {
|
||||
return this.service.getAlerts(
|
||||
days ? +days : 14,
|
||||
threshold ? +threshold : 3,
|
||||
await this.getAccessibleClassIds(req.user),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -345,6 +377,12 @@ export class AttendanceController {
|
||||
// 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.user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger DingTalk attendance import.
|
||||
* Mirrors `dws attendance check result` pipeline:
|
||||
@@ -352,16 +390,38 @@ export class AttendanceController {
|
||||
*/
|
||||
@Post('attendance-records/import/dingtalk')
|
||||
@RequirePermission('attendance:create')
|
||||
async importFromDingTalk(
|
||||
@Body() dto: DingTalkImportDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const canManageAll = this.canManageAllAttendance(req.user);
|
||||
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: dto.start,
|
||||
endDate: dto.end,
|
||||
userIds: dto.users?.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
autoMatch: dto.autoMatch ?? true,
|
||||
startDate,
|
||||
endDate,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
});
|
||||
|
||||
await this.logService.log({
|
||||
@@ -369,7 +429,7 @@ export class AttendanceController {
|
||||
username: req.user?.username,
|
||||
module: '考勤管理',
|
||||
action: '钉钉考勤导入',
|
||||
detail: `${dto.start}~${dto.end}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`,
|
||||
detail: `${startDate}~${endDate}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
@@ -385,7 +445,7 @@ export class AttendanceController {
|
||||
* 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')
|
||||
@Sse('attendance-records/import/dingtalk/stream')
|
||||
@RequirePermission('attendance:view')
|
||||
importProgressStream(): Observable<SseEvent> {
|
||||
return new Observable<SseEvent>((subscriber) => {
|
||||
@@ -401,5 +461,4 @@ export class AttendanceController {
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user