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

@@ -125,4 +125,101 @@ describe('AttendanceImportService', () => {
);
});
it('auto-matches previously imported duplicate records', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
checkType: 'OnDuty',
},
]);
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
const result = await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
autoMatch: true,
});
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
expect(result.matched).toBe(1);
});
it('scopes SSE progress events to the importing user', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-1',
userName: '李四',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T09:00:00.000Z',
checkId: 'check-2',
checkType: 'OnDuty',
},
]);
const events: Array<{ phase: string; userId?: number }> = [];
const sub = service.progress$.subscribe((event) => {
events.push({ phase: event.phase, userId: event.userId });
});
await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
userId: 42,
});
sub.unsubscribe();
expect(events.length).toBeGreaterThan(0);
for (const event of events) {
expect(event.userId).toBe(42);
}
});
it('emits userId undefined when import has no HTTP user', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-2',
userName: '王五',
workDate: '2026-07-02',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-02T10:00:00.000Z',
checkId: 'check-3',
checkType: 'OnDuty',
},
]);
const events: Array<{ phase: string; userId?: number }> = [];
const sub = service.progress$.subscribe((event) => {
events.push({ phase: event.phase, userId: event.userId });
});
await service.importFromDingTalk({
startDate: '2026-07-02',
endDate: '2026-07-02',
userIds: ['ding-2'],
});
sub.unsubscribe();
expect(events.length).toBeGreaterThan(0);
for (const event of events) {
expect(event.userId).toBeUndefined();
}
});
});

View File

@@ -28,6 +28,8 @@ export class AttendanceImportService {
/** RxJS Subject emitting live progress during import */
private progressSubject = new Subject<ImportProgressEvent>();
private isRunning = false;
/** ID of the user who triggered the current import (for SSE scoping) */
private importingUserId?: number;
constructor(
@InjectRepository(DingAttendanceRaw)
private readonly dingRawRepo: Repository<DingAttendanceRaw>,
@@ -60,7 +62,6 @@ export class AttendanceImportService {
* 1. Fetch attendance results from DingTalk (paginated)
* 2. Parse and validate each record
* 3. Deduplicate by `dingId` (unique in DB)
* 4. Batch-save to `ding_attendance_raw`
* 5. Optionally auto-match to students by name
*/
async importFromDingTalk(params: {
@@ -68,6 +69,8 @@ export class AttendanceImportService {
endDate: string;
userIds?: string[];
autoMatch?: boolean;
/** ID of the HTTP user triggering the import (for SSE event scoping) */
userId?: number;
}): Promise<ImportResult> {
if (this.isRunning) {
throw new Error('An import is already in progress');
@@ -75,6 +78,7 @@ export class AttendanceImportService {
const startedAt = Date.now();
this.isRunning = true;
this.importingUserId = params.userId;
// Safety timeout: auto-reset isRunning after 30 minutes in case of
// an unhandled exception that bypasses the finally block (extremely rare).
@@ -106,6 +110,7 @@ export class AttendanceImportService {
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
if (newRecords.length === 0) {
if (params.autoMatch) matched = await this.autoMatchUnmatched();
this.emit('complete', imported + skipped, total, 'Nothing new to import');
return { success: true, imported, skipped, matched, errors, duration: Date.now() - startedAt };
}
@@ -147,6 +152,7 @@ export class AttendanceImportService {
} finally {
clearTimeout(safetyTimer);
this.isRunning = false;
this.importingUserId = undefined;
}
}
@@ -312,6 +318,6 @@ export class AttendanceImportService {
message: string,
error?: string,
): void {
this.progressSubject.next({ phase, current, total, message, error });
this.progressSubject.next({ phase, current, total, message, error, userId: this.importingUserId });
}
}

View File

@@ -1,4 +1,5 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Subject } from 'rxjs';
import { AttendanceController } from './attendance.controller';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
@@ -11,6 +12,7 @@ describe('AttendanceController — DingTalk import scope', () => {
};
const importService = {
importFromDingTalk: jest.fn(),
progress$: undefined as unknown,
};
const logService = {
log: jest.fn(),
@@ -52,6 +54,7 @@ describe('AttendanceController — DingTalk import scope', () => {
endDate: '2026-07-10',
userIds: ['ding-today'],
autoMatch: true,
userId: 21,
});
jest.useRealTimers();
});
@@ -64,12 +67,12 @@ describe('AttendanceController — DingTalk import scope', () => {
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
);
expect(attendanceService.getTeacherClassDingUserIds).toHaveBeenCalledWith(21, 8, false);
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
startDate: '2026-07-01',
endDate: '2026-07-02',
userIds: ['ding-1', 'ding-2'],
autoMatch: true,
userId: 21,
});
});
@@ -144,12 +147,223 @@ describe('AttendanceController — DingTalk import scope', () => {
},
} as never,
);
expect(importService.importFromDingTalk).toHaveBeenLastCalledWith({
startDate: '2026-07-01',
endDate: '2026-07-02',
userIds: ['ding-1', 'ding-2'],
autoMatch: true,
userId: 7,
});
});
});
describe('AttendanceController — write data scope', () => {
const attendanceService = {
assertClassAccess: jest.fn(),
getAccessibleClassIds: jest.fn(),
getTeacherClassDingUserIds: jest.fn(),
batchCreate: jest.fn(),
generateFromSchedules: jest.fn(),
findAttendanceRecord: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
getLessonAttendance: jest.fn(),
createLessonAttendanceFromDingTalk: jest.fn(),
findAttendanceSession: jest.fn(),
completeLessonAttendance: jest.fn(),
};
const importService = { importFromDingTalk: jest.fn() };
const logService = { log: jest.fn().mockResolvedValue(undefined) };
const authzService = { can: jest.fn().mockReturnValue(false) };
const req = {
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false, roles: ['teacher'] },
headers: {},
};
let controller: AttendanceController;
beforeEach(() => {
jest.clearAllMocks();
authzService.can.mockReturnValue(false);
controller = new AttendanceController(
attendanceService as unknown as AttendanceService,
importService as unknown as AttendanceImportService,
logService as unknown as OperationLogsService,
authzService as never,
);
});
it('checks every distinct class in a manual attendance batch', async () => {
attendanceService.batchCreate.mockResolvedValue({ count: 2, records: [] });
const dto = {
records: [
{
studentId: 1,
classId: 8,
attendanceDate: '2026-07-01',
session: 'morning',
status: 'present',
},
{
studentId: 2,
classId: 9,
attendanceDate: '2026-07-01',
session: 'morning',
status: 'present',
},
],
};
await controller.batchCreate(dto, req as never);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 9, false);
});
it('rejects classless manual attendance records for a class-scoped teacher', async () => {
attendanceService.batchCreate.mockResolvedValue({ count: 1, records: [] });
const dto = {
records: [
{
studentId: 1,
attendanceDate: '2026-07-01',
session: 'morning',
status: 'present',
},
],
};
await expect(controller.batchCreate(dto, req as never)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(attendanceService.batchCreate).not.toHaveBeenCalled();
});
it('checks the schedule class before starting and completing lesson attendance', async () => {
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
importService.importFromDingTalk.mockResolvedValue({
success: true,
imported: 1,
skipped: 0,
matched: 1,
errors: [],
duration: 10,
});
attendanceService.getLessonAttendance.mockResolvedValue({
schedule: { id: 4, classId: 8 },
session: null,
records: [],
});
attendanceService.createLessonAttendanceFromDingTalk.mockResolvedValue({
schedule: { id: 4, classId: 8 },
session: { id: 90, classId: 8 },
records: [],
});
attendanceService.findAttendanceSession.mockResolvedValue({ id: 90, classId: 8 });
attendanceService.completeLessonAttendance.mockResolvedValue({
session: { id: 90, classId: 8, status: 'completed' },
records: [],
});
await controller.pullLessonAttendance('4', { date: '2026-07-11' }, req as never);
await controller.completeLessonAttendance('90', req as never);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false);
});
it('checks class access before generating attendance from schedules', async () => {
attendanceService.generateFromSchedules.mockResolvedValue({ count: 0, records: [] });
await controller.generateFromSchedules({ classId: 8 }, req as never);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
});
it('checks the attendance record owning class before update and delete', async () => {
attendanceService.findAttendanceRecord.mockResolvedValue({ id: 4, classId: 8 });
attendanceService.update.mockResolvedValue({ id: 4, classId: 8, status: 'late' });
attendanceService.remove.mockResolvedValue({ deleted: true });
await controller.update('4', { status: 'late' }, req as never);
await controller.remove('4', req as never);
expect(attendanceService.assertClassAccess).toHaveBeenCalledTimes(2);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false);
});
});
describe('AttendanceController — SSE progress scoping', () => {
let progressSubject: Subject<{ phase: string; userId?: number }>;
const importService = {
importFromDingTalk: jest.fn(),
get progress$() { return progressSubject.asObservable(); },
};
const attendanceService = {} as unknown as AttendanceService;
const logService = {} as unknown as OperationLogsService;
const authzService = {} as never;
let controller: AttendanceController;
beforeEach(() => {
progressSubject = new Subject<{ phase: string; userId?: number }>();
controller = new AttendanceController(
attendanceService,
importService as unknown as AttendanceImportService,
logService,
authzService,
);
});
afterEach(() => {
progressSubject.complete();
});
it('delivers events matching the requesting user id', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
} as never).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
});
progressSubject.next({ phase: 'fetching', userId: 42 });
progressSubject.next({ phase: 'complete', userId: 42 });
sub.unsubscribe();
expect(received).toHaveLength(2);
});
it('excludes events from a different user', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
} as never).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
});
progressSubject.next({ phase: 'fetching', userId: 99 }); // different user
progressSubject.next({ phase: 'complete', userId: 42 });
sub.unsubscribe();
// Only the matching event should arrive
expect(received).toHaveLength(1);
expect(received[0].userId).toBe(42);
});
it('excludes events with undefined userId (non-HTTP callers)', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
} as never).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
});
progressSubject.next({ phase: 'fetching' }); // no userId
progressSubject.next({ phase: 'complete', userId: 42 });
sub.unsubscribe();
// Only the event with matching userId should arrive
expect(received).toHaveLength(1);
expect(received[0].userId).toBe(42);
});
});

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

View File

@@ -0,0 +1,492 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AttendanceService } from './attendance.service';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
const createService = () => {
const attendanceRepo = {
create: jest.fn((value: Record<string, unknown>) => value),
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(async (records: unknown) => records),
remove: jest.fn(async (record: unknown) => record),
count: jest.fn(),
};
const dingRawRepo = { find: jest.fn() };
const scheduleRepo = { findOne: jest.fn() };
const classStudentRepo = { find: jest.fn() };
const sessionRepo = {
findOne: jest.fn(),
create: jest.fn((value: Record<string, unknown>) => ({ id: 90, ...value })),
save: jest.fn(async (value: unknown) => value),
};
const dataSource = {
transaction: jest.fn(
async (cb: (manager: { getRepository: jest.Mock }) => Promise<unknown>) => {
const managerGetRepo = jest.fn((entity: { name: string }) => {
if (entity.name === AttendanceSession.name) return sessionRepo;
if (entity.name === AttendanceRecord.name) return attendanceRepo;
return {};
});
return cb({ getRepository: managerGetRepo });
},
),
};
const service = new AttendanceService(
attendanceRepo as never,
dingRawRepo as never,
{} as never,
{} as never,
scheduleRepo as never,
classStudentRepo as never,
{} as never,
{} as never,
sessionRepo as never,
dataSource as unknown as DataSource,
);
return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, dataSource };
};
const endedSchedule = {
id: 4,
classId: 8,
weekDay: 6,
startTime: '09:00',
endTime: '10:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
subject: '\u6570\u5B66',
status: 'active',
scheduleType: 'INTERNAL',
};
describe('AttendanceService \u2014 DingTalk course attendance', () => {
it('creates one course session from DingTalk results after the lesson', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
{ studentId: 3, student: { id: 3, name: '\u738B\u4E94' } },
{ studentId: 4, student: { id: 4, name: '\u8D75\u516D' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
{ matchedStudentId: 3, attendanceType: 'OnDuty', timeResult: 'NotSigned' },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(sessionRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
}),
);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
expect.objectContaining({ studentId: 2, status: 'late', source: 'dingtalk' }),
expect.objectContaining({ studentId: 3, status: 'absent', source: 'dingtalk' }),
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
]);
expect(result.records).toHaveLength(4);
});
it('returns student relations after the first pull', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ student: { id: 1, name: '张三' } }),
]);
expect(result.records[0].student.name).toBe('张三');
});
it('uses the DingTalk punch nearest to this lesson start when a student has multiple shifts', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } }]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T02:00:00+08:00'),
},
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present' }),
]);
});
it('creates local attendance after the lesson starts', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
const now = new Date();
const weekDay = now.getDay() === 0 ? 7 : now.getDay();
scheduleRepo.findOne.mockResolvedValue({
...endedSchedule,
weekDay,
startTime: '00:00',
endTime: '23:59',
startDate: '2026-01-01',
endDate: '2026-12-31',
});
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
const today = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('-');
const result = await service.createLessonAttendanceFromDingTalk(4, today, 21);
expect(attendanceRepo.save).toHaveBeenCalled();
expect(result.records).toHaveLength(1);
});
it('returns a completed session as-is without refreshing', async () => {
const { service, attendanceRepo, scheduleRepo, sessionRepo } = createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'completed',
});
attendanceRepo.find.mockResolvedValue([{ id: 1, attendanceSessionId: 90, status: 'present' }]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(sessionRepo.save).not.toHaveBeenCalled();
expect(attendanceRepo.save).not.toHaveBeenCalled();
expect(result.records).toHaveLength(1);
});
it('refreshes an in_progress session from latest DingTalk data', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(sessionRepo.create).not.toHaveBeenCalled();
expect(attendanceRepo.save).toHaveBeenCalled();
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'present' }),
expect.objectContaining({ studentId: 2, status: 'late' }),
]),
);
expect(result.records).toHaveLength(2);
});
it('restores students missing from an existing empty session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
{ studentId: 2, student: { id: 2, name: '李四' } },
]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, student: { id: 1, name: '张三' } }),
expect.objectContaining({ studentId: 2, student: { id: 2, name: '李四' } }),
]);
expect(result.records).toHaveLength(2);
});
it('preserves manually corrected records when refreshing an in_progress session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'leave', source: 'manual' },
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late' },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'leave' }),
expect.objectContaining({ studentId: 2, status: 'late' }),
]),
);
expect(result.records).toHaveLength(2);
});
it('rejects completion when pending records exist', async () => {
const { service, sessionRepo, attendanceRepo } = createService();
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(1);
await expect(service.completeLessonAttendance(90, 21)).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('completes a pulled attendance session after teacher review', async () => {
const { service, sessionRepo, attendanceRepo } = createService();
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(0);
attendanceRepo.find.mockResolvedValue([
{ id: 1, status: 'present' },
{ id: 2, status: 'absent' },
]);
const result = await service.completeLessonAttendance(90, 21);
expect(sessionRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ id: 90, status: 'completed', completedBy: 21 }),
);
expect(result.session.status).toBe('completed');
});
it('throws when querying a missing schedule attendance session', async () => {
const { service, scheduleRepo } = createService();
scheduleRepo.findOne.mockResolvedValue(null);
await expect(service.getLessonAttendance(999, '2026-07-11')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('update() marks record source as manual so refresh preserves the correction', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
// Initial state: dingtalk-sourced record
attendanceRepo.findOne.mockResolvedValue({
id: 101,
studentId: 1,
attendanceSessionId: 90,
status: 'present',
source: 'dingtalk',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'manual' },
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
{ studentId: 2, student: { id: 2, name: '李四' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal' },
]);
// Step 1: update the record to absent via generic update()
const updated = await service.update(101, { status: 'absent' });
expect(updated.source).toBe('manual');
// Step 2: refresh in_progress session — manual record status must stay absent
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
(attendanceRepo.save as jest.Mock).mock.calls.length - 1
][0] as Array<{ studentId: number; status: string }>;
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'absent' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
]),
);
});
it('recovers from concurrent unique constraint on first session creation', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne
.mockResolvedValueOnce(null) // first check: no existing session
.mockResolvedValueOnce({
// recovery: the winning session
id: 77,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
// Simulate unique constraint on save
sessionRepo.save.mockRejectedValueOnce(
Object.assign(new Error('UNIQUE constraint failed'), {
code: 'SQLITE_CONSTRAINT',
errno: undefined,
}),
);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
dingRawRepo.find.mockResolvedValue([]);
attendanceRepo.find.mockResolvedValue([
{ id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(result.session.id).toBe(77);
expect(result.records).toHaveLength(1);
});
it('completeLessonAttendance runs inside a transaction', async () => {
const { service, sessionRepo, attendanceRepo, dataSource } = createService();
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(0);
attendanceRepo.find.mockResolvedValue([
{ id: 1, status: 'present' },
]);
await service.completeLessonAttendance(90, 21);
expect(dataSource.transaction).toHaveBeenCalled();
});
it('update() throws when the parent session is completed', async () => {
const { service, attendanceRepo, sessionRepo } = createService();
attendanceRepo.findOne.mockResolvedValue({
id: 101,
attendanceSessionId: 90,
status: 'present',
});
sessionRepo.findOne.mockResolvedValue({
id: 90,
status: 'completed',
});
await expect(service.update(101, { status: 'absent' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('remove() throws when the parent session is completed', async () => {
const { service, attendanceRepo, sessionRepo } = createService();
attendanceRepo.findOne.mockResolvedValue({
id: 101,
attendanceSessionId: 90,
});
sessionRepo.findOne.mockResolvedValue({
id: 90,
status: 'completed',
});
await expect(service.remove(101)).rejects.toBeInstanceOf(BadRequestException);
});
it('update() allows modification when the parent session is in_progress', async () => {
const { service, attendanceRepo, sessionRepo } = createService();
attendanceRepo.findOne.mockResolvedValue({
id: 101,
attendanceSessionId: 90,
status: 'present',
});
sessionRepo.findOne.mockResolvedValue({
id: 90,
status: 'in_progress',
});
const result = await service.update(101, { status: 'absent' });
expect(result.source).toBe('manual');
});
});

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
import { AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { AttendanceController } from './attendance.controller';
@@ -9,7 +9,7 @@ import { IntegrationModule } from '../integration/integration.module';
@Module({
imports: [
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
OperationLogsModule,
IntegrationModule,
],

View File

@@ -1,9 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getDataSourceToken } from '@nestjs/typeorm';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { Repository } from 'typeorm';
import { AttendanceService } from './attendance.service';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
import { Class } from '../entities/class.entity';
import { Student } from '../entities/student.entity';
@@ -53,6 +55,8 @@ describe('AttendanceService — batchCreate', () => {
{ provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo },
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
{ provide: getDataSourceToken(), useValue: { transaction: jest.fn() } },
],
}).compile();
@@ -130,6 +134,8 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
classStudentRepo as never,
mappingRepo as never,
classTeacherRepo as never,
{} as never,
{} as never,
);
beforeEach(() => {
@@ -203,6 +209,8 @@ describe('AttendanceService — DingTalk raw query', () => {
classStudentRepo as never,
mappingRepo as never,
{} as never,
{} as never,
{} as never,
);
await expect(
@@ -219,3 +227,205 @@ describe('AttendanceService — DingTalk raw query', () => {
expect(qb.take).toHaveBeenCalledWith(10);
});
});
// ── Session serialization tests ──
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('AttendanceService — session serialization', () => {
const sessionId = 100;
const recordId = 200;
const sessionInProgress = { id: sessionId, status: 'in_progress' };
const sessionCompleted = { id: sessionId, status: 'completed' };
const record: Record<string, unknown> = {
id: recordId,
attendanceSessionId: sessionId,
studentId: 1,
status: 'present',
source: 'dingtalk',
attendanceDate: '2026-07-01',
session: 'morning',
};
function makeService(
dataSourceMock: { transaction: jest.Mock },
attendanceRepoOverrides?: Record<string, jest.Mock>,
) {
const defaultAttendanceRepo = {
findOne: jest.fn().mockResolvedValue(record),
find: jest.fn().mockResolvedValue([record]),
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
remove: jest.fn().mockResolvedValue(undefined),
create: jest.fn(),
createQueryBuilder: jest.fn(),
};
const attendanceRepo = { ...defaultAttendanceRepo, ...attendanceRepoOverrides };
return new AttendanceService(
attendanceRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
dataSourceMock as never,
);
}
function makeTxManager(sessionStatus: string) {
const session = sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
const sessionRepo = {
findOne: jest.fn().mockResolvedValue(session),
save: jest.fn().mockImplementation((s: unknown) => Promise.resolve(s)),
};
const recordRepo = {
findOne: jest.fn().mockResolvedValue(record),
find: jest.fn().mockResolvedValue([record]),
count: jest.fn().mockResolvedValue(0),
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
remove: jest.fn().mockResolvedValue(undefined),
};
return {
getRepository: jest.fn((entity: unknown) => {
if (entity === AttendanceSession) return sessionRepo;
if (entity === AttendanceRecord) return recordRepo;
throw new Error('Unexpected entity');
}),
sessionRepo,
recordRepo,
};
}
it('complete holds lock; queued update is rejected after session becomes completed', async () => {
// For this test, the manager has a completed session
// The complete callback is stalled, update queues and then finds completed
const manager = makeTxManager('completed');
const completeStall = deferred<unknown>();
const txMock = jest
.fn()
.mockImplementationOnce(() => completeStall.promise)
.mockImplementationOnce((cb: (m: unknown) => unknown) => cb(manager));
const svc = makeService({ transaction: txMock });
const completeP = svc.completeLessonAttendance(sessionId, 1);
const updateP = svc.update(recordId, { status: 'absent' });
completeStall.resolve({ session: sessionCompleted, records: [record] });
await expect(completeP).resolves.toEqual({ session: sessionCompleted, records: [record] });
await expect(updateP).rejects.toThrow(BadRequestException);
expect(manager.sessionRepo.findOne).toHaveBeenCalled();
});
it('update holds lock; complete reads current state after update finishes', async () => {
const manager = makeTxManager('in_progress');
const updateStall = deferred<unknown>();
// Track call order
const callOrder: string[] = [];
let transactionsRun = 0;
const txMock = jest.fn().mockImplementation((cb: (m: unknown) => unknown) => {
transactionsRun++;
if (transactionsRun === 1) {
// update: stall
callOrder.push('update-tx-started');
return updateStall.promise.then((v) => {
callOrder.push('update-tx-resolved');
return v;
});
}
// complete: actually run the callback
callOrder.push('complete-tx-started');
return cb(manager);
});
const svc = makeService({ transaction: txMock });
// Start update but DON'T await — it will stall
const updateP = svc.update(recordId, { status: 'absent' });
// Give update time to enter the mutex and transaction
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
// Start complete while update is stalled
const completeP = svc.completeLessonAttendance(sessionId, 1);
// Another tick
await Promise.resolve();
// Resolve update's stalled transaction
updateStall.resolve(record);
const updateResult = await updateP;
expect(updateResult).toEqual(record);
const completeResult = await completeP;
// Verify the callback was actually run correctly
expect(callOrder).toContain('update-tx-started');
expect(callOrder).toContain('complete-tx-started');
expect(completeResult).toHaveProperty('session');
expect(completeResult).toHaveProperty('records');
});
it('records without attendanceSessionId bypass the mutex and keep original behaviour', async () => {
const noSessionRecord = { ...record, attendanceSessionId: null };
const overrides = {
findOne: jest.fn().mockResolvedValue(noSessionRecord),
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
};
const txMock = jest.fn();
const svc = makeService({ transaction: txMock }, overrides);
const result = await svc.update(recordId, { status: 'absent' });
expect(result).toEqual(noSessionRecord);
expect(txMock).not.toHaveBeenCalled();
const removeResult = await svc.remove(recordId);
expect(removeResult).toEqual({ deleted: true });
});
it('complete is idempotent: returns current state when session already completed', async () => {
const manager = makeTxManager('completed');
const txMock = jest
.fn()
.mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
const svc = makeService({ transaction: txMock });
const result = await svc.completeLessonAttendance(sessionId, 1);
expect(result).toHaveProperty('session');
expect(result).toHaveProperty('records');
expect(manager.recordRepo.count).not.toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,18 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ClassTeacher, ScheduleType, StudentDingMapping } from '../entities';
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual, DataSource } from 'typeorm';
import {
AttendanceRecord,
AttendanceSession,
DingAttendanceRaw,
Class,
Student,
ClassSchedule,
ClassStudent,
ClassTeacher,
ScheduleType,
StudentDingMapping,
} from '../entities';
import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
@@ -18,6 +25,26 @@ import {
GenerateFromSchedulesDto,
} from './dto/attendance.dto';
/** Keyed mutex serializing operations on the same attendance session. */
class SessionMutex {
private queueTails = new Map<number, Promise<void>>();
async runExclusive<T>(sessionId: number, fn: () => Promise<T>): Promise<T> {
const tail = this.queueTails.get(sessionId) ?? Promise.resolve();
let release!: () => void;
const newTail = new Promise<void>((resolve) => { release = resolve; });
this.queueTails.set(sessionId, newTail);
await tail;
try {
return await fn();
} finally {
release();
if (this.queueTails.get(sessionId) === newTail) {
this.queueTails.delete(sessionId);
}
}
}
}
@Injectable()
export class AttendanceService {
constructor(
@@ -37,8 +64,13 @@ export class AttendanceService {
private studentDingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(ClassTeacher)
private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession)
private attendanceSessionRepo: Repository<AttendanceSession>,
private dataSource: DataSource,
) {}
private sessionMutex = new SessionMutex();
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
@@ -112,13 +144,315 @@ export class AttendanceService {
return userIds.sort();
}
private async getScheduleOccurrence(scheduleId: number, lessonDate: string) {
const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('排课记录不存在');
if (schedule.scheduleType !== ScheduleType.INTERNAL || schedule.status !== 'active') {
throw new BadRequestException('该排课不能进行课程考勤');
}
if (schedule.classId == null) throw new BadRequestException('该排课未关联班级');
if (lessonDate < schedule.startDate || lessonDate > schedule.endDate) {
throw new BadRequestException('所选日期不在排课有效期内');
}
const date = new Date(`${lessonDate}T00:00:00`);
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
if (weekDay !== schedule.weekDay) throw new BadRequestException('所选日期不是该课程的上课日');
return schedule;
}
async getLessonAttendance(scheduleId: number, lessonDate: string) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const session = await this.attendanceSessionRepo.findOne({
where: { scheduleId, lessonDate },
});
const records = session
? await this.attendanceRepo.find({
where: { attendanceSessionId: session.id },
relations: ['student'],
order: { studentId: 'ASC' },
})
: [];
return { schedule, session, records };
}
private selectDingTalkRecordsForLesson(
records: DingAttendanceRaw[],
lessonDate: string,
startTime: string,
endTime: string,
): DingAttendanceRaw[] {
const [startHour, startMinute] = startTime.split(':').map(Number);
const [endHour, endMinute] = endTime.split(':').map(Number);
const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime();
if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000;
const windowStart = start - 3 * 60 * 60 * 1000;
const windowEnd = end + 3 * 60 * 60 * 1000;
const timed = records.filter((record) => {
const time = record.checkInTime ?? record.checkOutTime;
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd;
});
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
}
private mapDingTalkStatus(records: DingAttendanceRaw[]): string {
const results = new Set(records.map((record) => record.timeResult?.toLowerCase()));
if (results.has('late') || results.has('seriouslate')) return 'late';
if (
results.has('notsigned') ||
results.has('absenteeism') ||
results.has('absent')
) {
return 'absent';
}
if (results.has('leave') || results.has('vacation')) return 'leave';
if (results.has('normal')) return 'present';
return 'pending';
}
async createLessonAttendanceFromDingTalk(
scheduleId: number,
lessonDate: string,
userId: number,
) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const now = new Date();
const today = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('-');
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
if (lessonDate === today) {
const [hour, minute] = schedule.startTime.split(':').map(Number);
const startMinute = hour * 60 + minute;
const currentMinute = now.getHours() * 60 + now.getMinutes();
if (currentMinute < startMinute) {
throw new BadRequestException('课程尚未开始,不能拉取考勤');
}
}
const existing = await this.attendanceSessionRepo.findOne({
where: { scheduleId, lessonDate },
});
if (existing) {
if (existing.status === 'completed') {
const records = await this.attendanceRepo.find({
where: { attendanceSessionId: existing.id },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { schedule, session: existing, records };
}
// Refresh in_progress session from latest DingTalk data
return this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: existing.id },
order: { studentId: 'ASC' },
});
const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' },
relations: ['student'],
});
const studentsById = new Map(
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
);
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
const updatedRecords = existingRecords.map((record) => {
record.student = studentsById.get(record.studentId)!;
// Preserve manually corrected records.
if (record.source !== 'dingtalk') return record;
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(record.studentId) ?? [],
lessonDate,
schedule.startTime,
schedule.endTime,
);
record.status = this.mapDingTalkStatus(raw);
record.remark = raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : null;
return record;
});
for (const classStudent of classStudents) {
if (existingStudentIds.has(classStudent.studentId)) continue;
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
lessonDate,
schedule.startTime,
schedule.endTime,
);
updatedRecords.push(
recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: existing.id,
attendanceDate: lessonDate,
session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw),
source: 'dingtalk',
remark: raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : undefined,
}),
);
}
const saved = await recordRepo.save(updatedRecords);
return { schedule, session: existing, records: saved };
});
}
// First pull: create session and records atomically
return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' },
relations: ['student'],
});
if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
let session: AttendanceSession;
try {
session = await sessionRepo.save(
sessionRepo.create({
scheduleId,
classId: schedule.classId!,
lessonDate,
status: 'in_progress',
startedBy: userId,
startedAt: new Date(),
}),
);
} catch (err: unknown) {
const code = (err as Record<string, unknown>).code;
const errno = (err as Record<string, unknown>).errno;
// MySQL: ER_DUP_ENTRY or errno 1062; SQLite: SQLITE_CONSTRAINT
if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
const existing = await sessionRepo.findOne({
where: { scheduleId, lessonDate },
});
if (existing) {
session = existing;
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: session.id },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { schedule, session, records: existingRecords };
}
}
throw err;
}
const records = classStudents.map((classStudent) => {
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
lessonDate,
schedule.startTime,
schedule.endTime,
);
return recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: session.id,
attendanceDate: lessonDate,
session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw),
source: 'dingtalk',
remark: raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : undefined,
});
});
const saved = await recordRepo.save(records);
return { schedule, session, records: saved };
});
}
private async fetchDingTalkRawByStudent(
classId: number,
lessonDate: string,
): Promise<Map<number, DingAttendanceRaw[]>> {
const classStudents = await this.classStudentRepo.find({
where: { classId, status: 'active' },
});
if (classStudents.length === 0) return new Map();
const studentIds = classStudents.map((cs) => cs.studentId);
const rawRecords = await this.dingRawRepo.find({
where: {
attendanceDate: lessonDate,
matchedStudentId: In(studentIds),
},
});
const rawByStudent = new Map<number, DingAttendanceRaw[]>();
for (const raw of rawRecords) {
if (raw.matchedStudentId == null) continue;
const arr = rawByStudent.get(raw.matchedStudentId) ?? [];
arr.push(raw);
rawByStudent.set(raw.matchedStudentId, arr);
}
return rawByStudent;
}
async completeLessonAttendance(sessionId: number, userId: number) {
return this.sessionMutex.runExclusive(sessionId, () =>
this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const session = await sessionRepo.findOne({ where: { id: sessionId } });
if (!session) throw new NotFoundException('课程考勤场次不存在');
// Re-check under lock: if already completed, return current state idempotently
if (session.status === 'completed') {
const records = await recordRepo.find({
where: { attendanceSessionId: sessionId },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session, records };
}
const pendingRecords = await recordRepo.count({
where: { attendanceSessionId: sessionId, status: 'pending' },
});
if (pendingRecords > 0) {
throw new BadRequestException('存在未处理的考勤记录,无法完成考勤');
}
session.status = 'completed';
session.completedBy = userId;
session.completedAt = new Date();
const savedSession = await sessionRepo.save(session);
const records = await recordRepo.find({
where: { attendanceSessionId: sessionId },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session: savedSession, records };
}),
);
}
async findAttendanceSession(id: number) {
const session = await this.attendanceSessionRepo.findOne({ where: { id } });
if (!session) throw new NotFoundException('课程考勤场次不存在');
return session;
}
// ── Batch create attendance records ──
async batchCreate(dto: BatchCreateAttendanceDto) {
if (!dto.records || dto.records.length === 0) {
throw new BadRequestException('records array must not be empty');
}
const entities = dto.records.map((r) => {
const entity = this.attendanceRepo.create({
studentId: r.studentId,
@@ -209,7 +543,9 @@ export class AttendanceService {
}
// ── Generate attendance records from schedules (optional date range, defaults to current week) ──
async generateFromSchedules(dto: GenerateFromSchedulesDto): Promise<{ count: number; records: AttendanceRecord[] }> {
async generateFromSchedules(
dto: GenerateFromSchedulesDto,
): Promise<{ count: number; records: AttendanceRecord[] }> {
const { classId, startDate, endDate } = dto;
// Default to current week (MondaySunday)
@@ -233,7 +569,6 @@ export class AttendanceService {
});
}
private mapScheduleTimeToSession(startTime: string): string {
const hour = parseInt(startTime.slice(0, 2), 10);
if (hour < 8) return 'morning_reading';
@@ -243,14 +578,15 @@ export class AttendanceService {
return 'night_check';
}
// ── Attendance summary ──
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0)
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) {
@@ -337,27 +673,32 @@ export class AttendanceService {
}
// ── List attendance records with filters ──
async findAll(query: {
classId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: number;
}, accessibleClassIds?: number[]) {
async findAll(
query: {
classId?: number;
scheduleId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: number;
},
accessibleClassIds?: number[],
) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
if (query.scheduleId) {
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
}
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
else if (accessibleClassIds) {
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
@@ -391,7 +732,6 @@ export class AttendanceService {
.select('DISTINCT ar.classId', 'classId')
.where('ar.classId IS NOT NULL');
const rows = accessibleClassIds
? accessibleClassIds.map((classId) => ({ classId }))
: await qb.orderBy('ar.classId', 'ASC').getRawMany();
@@ -432,7 +772,9 @@ export class AttendanceService {
const mappings = await this.studentDingMappingRepo.find({
where: { studentId: In(studentIds) },
});
const dingUserIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
const dingUserIds = [
...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean)),
];
if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds });
}
@@ -488,22 +830,23 @@ export class AttendanceService {
}
// ── Export all attendance records with filters (no pagination) ──
async findAllForExport(query: {
classId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
}, accessibleClassIds?: number[]) {
async findAllForExport(
query: {
classId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
},
accessibleClassIds?: number[],
) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
else if (accessibleClassIds) {
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
@@ -528,34 +871,85 @@ export class AttendanceService {
return qb.getMany();
}
// ── Update a single attendance record ──
async update(id: number, dto: UpdateAttendanceRecordDto) {
async findAttendanceRecord(id: number) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
if (dto.status !== undefined) {
record.status = dto.status;
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
}
return this.attendanceRepo.save(record);
return record;
}
// ── Delete a single attendance record ──
async remove(id: number) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
// ── Update a single attendance record ──
async update(id: number, dto: UpdateAttendanceRecordDto) {
const record = await this.findAttendanceRecord(id);
// Records without a lesson session keep original behaviour
if (record.attendanceSessionId == null) {
if (dto.status !== undefined) {
record.status = dto.status;
record.source = 'manual';
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
record.source = 'manual';
}
return this.attendanceRepo.save(record);
}
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const sessionRepo = manager.getRepository(AttendanceSession);
await this.attendanceRepo.remove(record);
return { deleted: true };
// Re-check session status inside the transaction while holding the lock
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
if (!session || session.status === 'completed') {
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
}
const freshRecord = await recordRepo.findOne({ where: { id } });
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
if (dto.status !== undefined) {
freshRecord.status = dto.status;
freshRecord.source = 'manual';
}
if (dto.remark !== undefined) {
freshRecord.remark = dto.remark;
freshRecord.source = 'manual';
}
return recordRepo.save(freshRecord);
}),
);
}
// ── Delete a single attendance record ──
async remove(id: number) {
const record = await this.findAttendanceRecord(id);
// Records without a lesson session keep original behaviour
if (record.attendanceSessionId == null) {
await this.attendanceRepo.remove(record);
return { deleted: true };
}
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const sessionRepo = manager.getRepository(AttendanceSession);
// Re-check session status inside the transaction while holding the lock
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
if (!session || session.status === 'completed') {
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
}
const freshRecord = await recordRepo.findOne({ where: { id } });
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
await recordRepo.remove(freshRecord);
return { deleted: true };
}),
);
}
// ── Class-based attendance report ──
@@ -569,8 +963,7 @@ export class AttendanceService {
.addSelect('COUNT(*)', 'count');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
else if (accessibleClassIds) {
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
@@ -585,14 +978,17 @@ export class AttendanceService {
const rawRows = await qb.getRawMany();
// Aggregate by class
const classMap = new Map<number, {
classId: number;
className: string;
present: number;
absent: number;
late: number;
leave: number;
}>();
const classMap = new Map<
number,
{
classId: number;
className: string;
present: number;
absent: number;
late: number;
leave: number;
}
>();
for (const row of rawRows) {
if (!row.classId) continue;
@@ -637,9 +1033,10 @@ export class AttendanceService {
.leftJoinAndSelect('a.student', 'student')
.leftJoinAndSelect('a.class', 'class');
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] });
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr }).andWhere(
'a.status IN (:...statuses)',
{ statuses: ['absent', 'late'] },
);
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds });
@@ -650,11 +1047,15 @@ export class AttendanceService {
.getMany();
const alerts: Array<{
studentId: number; studentName: string; className: string;
type: string; count: number; lastDate: string;
studentId: number;
studentName: string;
className: string;
type: string;
count: number;
lastDate: string;
}> = [];
let current: typeof alerts[0] | null = null;
let current: (typeof alerts)[0] | null = null;
for (const r of records) {
const name = (r.student as any)?.name || '';
const className = (r.class as any)?.name || '';
@@ -664,10 +1065,17 @@ export class AttendanceService {
if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;
} else {
if (current && current.count >= threshold) alerts.push({ ...current });
current = { studentId: r.studentId, studentName: name, className, type: status, count: 1, lastDate: r.attendanceDate };
current = {
studentId: r.studentId,
studentName: name,
className,
type: status,
count: 1,
lastDate: r.attendanceDate,
};
}
}
if (current && current.count >= threshold) alerts.push(current);
return alerts;
}
}
}

View File

@@ -55,5 +55,33 @@ describe('DingTalkService — attendance records', () => {
).rejects.toThrow('userIds');
expect(global.fetch).toBeUndefined();
});
it('keeps DingTalk work dates in China local time', async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
errcode: 0,
errmsg: 'ok',
recordresult: [
{
id: 1,
userId: 'ding-1',
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
sourceType: 'USER',
checkType: 'OnDuty',
timeResult: 'Normal',
},
],
}),
}) as jest.MockedFunction<typeof fetch>;
const [record] = await service.fetchAttendanceResults({
startDate: '2026-07-12',
endDate: '2026-07-12',
userIds: ['ding-1'],
});
expect(record.workDate).toBe('2026-07-12');
});
});

View File

@@ -111,6 +111,11 @@ export class QueryAttendanceRecordsDto {
@Type(() => Number)
classId?: number;
@IsOptional()
@IsInt()
@Type(() => Number)
scheduleId?: number;
@IsOptional()
@IsDateString()
dateFrom?: string;
@@ -204,3 +209,15 @@ export class GenerateFromSchedulesDto {
@IsDateString()
endDate?: string;
}
export class LessonAttendanceQueryDto {
@IsDateString()
@IsNotEmpty()
date: string;
}
export class StartLessonAttendanceDto {
@IsDateString()
@IsNotEmpty()
date: string;
}

View File

@@ -47,6 +47,8 @@ export interface ImportProgressEvent {
message: string;
/** Error message (only when phase === 'error') */
error?: string;
/** ID of the user who triggered the import (undefined for non-HTTP callers) */
userId?: number;
}
/**