forked from wangziqi/gongxue-base
fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, filter } from 'rxjs';
|
||||
import type { Request as ExpressRequest, Response } from 'express';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
AttendanceReportQueryDto,
|
||||
UpdateAttendanceRecordDto,
|
||||
GenerateFromSchedulesDto,
|
||||
LessonAttendanceQueryDto,
|
||||
StartLessonAttendanceDto,
|
||||
} from './dto/attendance.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
@@ -88,11 +90,94 @@ export class AttendanceController {
|
||||
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
|
||||
}
|
||||
|
||||
@Get('attendance-lessons/schedules/:scheduleId')
|
||||
@RequirePermission('attendance:view')
|
||||
async getLessonAttendance(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Query() query: LessonAttendanceQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const result = await this.service.getLessonAttendance(+scheduleId, query.date);
|
||||
await this.assertClassAccess(req, result.schedule.classId!);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('attendance-lessons/schedules/:scheduleId/pull')
|
||||
@RequirePermission('attendance:create')
|
||||
async pullLessonAttendance(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Body() dto: StartLessonAttendanceDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const schedule = await this.service.getLessonAttendance(+scheduleId, dto.date);
|
||||
await this.assertClassAccess(req, schedule.schedule.classId!);
|
||||
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
||||
req.user.id,
|
||||
schedule.schedule.classId!,
|
||||
this.canManageAllAttendance(req),
|
||||
);
|
||||
const importResult = await this.importService.importFromDingTalk({
|
||||
startDate: dto.date,
|
||||
endDate: dto.date,
|
||||
userIds: importClassIds,
|
||||
autoMatch: true,
|
||||
userId: req.user.id,
|
||||
});
|
||||
const result = await this.service.createLessonAttendanceFromDingTalk(
|
||||
+scheduleId,
|
||||
dto.date,
|
||||
req.user.id,
|
||||
);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: schedule.session ? '查看已拉取课程考勤' : '拉取钉钉课程考勤',
|
||||
targetId: result.session.id,
|
||||
targetType: 'attendanceSession',
|
||||
detail: `排课${scheduleId} 日期${dto.date},钉钉新增${importResult.imported}条,匹配${importResult.matched}条`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('attendance-lessons/:sessionId/complete')
|
||||
@RequirePermission('attendance:create')
|
||||
async completeLessonAttendance(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const session = await this.service.findAttendanceSession(+sessionId);
|
||||
await this.assertClassAccess(req, session.classId);
|
||||
const result = await this.service.completeLessonAttendance(+sessionId, req.user.id);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: '完成课程点名',
|
||||
targetId: +sessionId,
|
||||
targetType: 'attendanceSession',
|
||||
detail: `班级${session.classId} 日期${session.lessonDate}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
@Post('attendance-records/batch')
|
||||
@RequirePermission('attendance:create')
|
||||
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const canManageAll = this.canManageAllAttendance(req);
|
||||
if (!canManageAll && dto.records.some((record) => record.classId == null)) {
|
||||
throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');
|
||||
}
|
||||
const classIds = [
|
||||
...new Set(
|
||||
dto.records.map((record) => record.classId).filter((id): id is number => id != null),
|
||||
),
|
||||
];
|
||||
for (const classId of classIds) {
|
||||
await this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
||||
}
|
||||
const result = await this.service.batchCreate(dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -111,6 +196,7 @@ export class AttendanceController {
|
||||
@RequirePermission('attendance:create')
|
||||
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.assertClassAccess(req, dto.classId);
|
||||
const result = await this.service.generateFromSchedules(dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -190,13 +276,18 @@ export class AttendanceController {
|
||||
|
||||
// ── Update a single attendance record ──
|
||||
@Put('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit')
|
||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAttendanceRecordDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const existing = await this.service.findAttendanceRecord(+id);
|
||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||
throw new ForbiddenException('无权修改未关联班级的考勤记录');
|
||||
}
|
||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||
const result = await this.service.update(+id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -214,9 +305,14 @@ export class AttendanceController {
|
||||
|
||||
// ── Delete a single attendance record ──
|
||||
@Delete('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit')
|
||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const existing = await this.service.findAttendanceRecord(+id);
|
||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
||||
}
|
||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -421,12 +517,12 @@ export class AttendanceController {
|
||||
|
||||
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({
|
||||
@@ -452,17 +548,22 @@ export class AttendanceController {
|
||||
*/
|
||||
@Sse('attendance-records/import/dingtalk/stream')
|
||||
@RequirePermission('attendance:view')
|
||||
importProgressStream(): Observable<SseEvent> {
|
||||
importProgressStream(@Request() req: { user: RequestUser }): Observable<SseEvent> {
|
||||
const userId = req.user.id;
|
||||
return new Observable<SseEvent>((subscriber) => {
|
||||
const subscription = this.importService.progress$.subscribe({
|
||||
next: (event) => {
|
||||
subscriber.next({ data: JSON.stringify(event) });
|
||||
if (event.phase === 'complete' || event.phase === 'error') {
|
||||
subscriber.complete();
|
||||
}
|
||||
},
|
||||
error: (err: unknown) => subscriber.error(err),
|
||||
});
|
||||
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