fix permissions and teacher attendance workflows

This commit is contained in:
2026-07-10 20:40:52 +08:00
parent 247879f276
commit 8ed1682b90
95 changed files with 4745 additions and 1237 deletions

View File

@@ -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();
});
}
}
}