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:
2026-07-12 22:59:03 +08:00
parent b6fca99390
commit cc4f4dae4e
69 changed files with 6262 additions and 1980 deletions

View File

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