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

@@ -28,6 +28,7 @@ import {
ClassTeacher,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
DingAttendanceRaw,
SyncLog,
SyncState,
@@ -116,6 +117,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
Role,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
DingAttendanceRaw,
Notification,
StudentProfile,

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;
}
/**

View File

@@ -1,13 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping } from '../entities';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping } from '../entities';
import { ClassesService } from './classes.service';
import { ClassesController } from './classes.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
controllers: [ClassesController],
providers: [ClassesService],
exports: [ClassesService],

View File

@@ -2,6 +2,7 @@ import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -12,6 +13,7 @@ import {
ClassTeacher,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
Classroom,
Student,
StudentDingMapping,
@@ -45,6 +47,8 @@ export class ClassesService {
private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceRecord)
private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(AttendanceSession)
private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectRepository(Student)
private studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping)
@@ -288,6 +292,16 @@ export class ClassesService {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
if (!cls.isArchived) throw new BadRequestException('请先归档再删除');
const sessionCount = await this.attendanceSessionRepo.count({
where: { classId: id },
});
if (sessionCount > 0) {
throw new ConflictException(
`无法删除已产生 ${sessionCount} 个考勤场次的班级。请先取消或停用班级以保护历史考勤数据。`,
);
}
await this.classRepo.remove(cls);
return { success: true };
}

View File

@@ -0,0 +1,380 @@
import Database from 'better-sqlite3';
type SqliteDB = InstanceType<typeof Database>;
/**
* Real SQLite foreign-key constraint tests.
*
* These tests use the `better-sqlite3` driver directly (in-memory) to verify
* that ON DELETE RESTRICT is enforced at the database level, not just in
* application-layer guards.
*/
describe('attendance_sessions FK RESTRICT — real SQLite', () => {
let db: SqliteDB;
function createSchema(): void {
db.exec('PRAGMA foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_archived INTEGER DEFAULT 0
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS class_schedule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER,
week_day INTEGER NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS attendance_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL,
class_id INTEGER NOT NULL,
lesson_date DATE NOT NULL,
status TEXT DEFAULT 'in_progress',
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
)
`);
}
beforeEach(() => {
db = new Database(':memory:');
createSchema();
});
afterEach(() => {
db.close();
});
it('blocks class deletion when attendance sessions reference it', () => {
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
db.exec(
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
);
expect(() => {
db.exec('DELETE FROM classes WHERE id = 1');
}).toThrow();
});
it('allows class deletion when no attendance sessions reference it', () => {
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
expect(() => {
db.exec('DELETE FROM classes WHERE id = 1');
}).not.toThrow();
const remaining = db.prepare('SELECT COUNT(*) as cnt FROM classes').get() as {
cnt: number;
};
expect(remaining.cnt).toBe(0);
});
it('blocks schedule deletion when attendance sessions reference it', () => {
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
db.exec(
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
);
expect(() => {
db.exec('DELETE FROM class_schedule WHERE id = 1');
}).toThrow();
});
it('PRAGMA foreign_key_list confirms both FKs are present', () => {
// Use raw SQL PRAGMA to avoid better-sqlite3 pragma API quirks
const rows = db.prepare("PRAGMA foreign_key_list('attendance_sessions')").all() as Array<{
id: number;
seq: number;
table: string;
from: string;
to: string;
on_update: string;
on_delete: string;
match: string;
}>;
expect(rows.length).toBe(2);
const scheduleFk = rows.find((fk) => fk.from === 'schedule_id');
expect(scheduleFk).toBeDefined();
expect(scheduleFk!.table).toBe('class_schedule');
expect(scheduleFk!.on_delete).toBe('RESTRICT');
const classFk = rows.find((fk) => fk.from === 'class_id');
expect(classFk).toBeDefined();
expect(classFk!.table).toBe('classes');
expect(classFk!.on_delete).toBe('RESTRICT');
});
it('FK pragma respects ON DELETE RESTRICT for class_id — data survives failed delete', () => {
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
db.exec(
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
);
// Verify the session exists
const session = db
.prepare('SELECT * FROM attendance_sessions WHERE class_id = 1')
.get() as Record<string, unknown>;
expect(session).toBeDefined();
// Delete should fail
expect(() => db.exec('DELETE FROM classes WHERE id = 1')).toThrow();
// Session should still exist after failed delete
const after = db
.prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE class_id = 1')
.get() as { cnt: number };
expect(after.cnt).toBe(1);
});
});
/**
* Integration test: simulate the protectAttendanceHistory SQLite migration.
*
* Creates tables WITHOUT foreign keys (pre-migration state), inserts parent
* session and child attendance_record, runs the table-rebuild migration
* (PRAGMA foreign_keys=OFF, rebuild both tables, PRAGMA foreign_keys=ON,
* foreign_key_check), then verifies:
* - attendance_record.attendance_session_id is preserved
* - RESTRICT still blocks class/schedule deletion
*/
describe('protectAttendanceHistory SQLite migration — integration', () => {
let db: SqliteDB;
function createPreMigrationSchema(): void {
// Schema WITHOUT foreign keys on attendance_sessions (pre-migration)
db.exec('PRAGMA foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_archived INTEGER DEFAULT 0
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS class_schedule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER,
week_day INTEGER NOT NULL
)
`);
// attendance_sessions WITHOUT foreign keys
db.exec(`
CREATE TABLE IF NOT EXISTS attendance_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL,
class_id INTEGER NOT NULL,
lesson_date DATE NOT NULL,
status TEXT DEFAULT 'in_progress',
started_by INTEGER,
started_at DATETIME,
completed_by INTEGER,
completed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
// Legacy columns came first; course-attendance columns were appended later.
db.exec(`
CREATE TABLE IF NOT EXISTS attendance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
class_id INTEGER,
attendance_date DATE NOT NULL,
session VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
remark VARCHAR(200),
source VARCHAR(20) DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
schedule_id INTEGER,
attendance_session_id INTEGER
)
`);
}
function runMigration(): void {
// Step 1: PRAGMA foreign_keys = OFF outside transaction
db.exec('PRAGMA foreign_keys = OFF');
try {
db.exec('BEGIN');
try {
// Rebuild attendance_sessions with FKs
db.exec(`
CREATE TABLE attendance_sessions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL,
class_id INTEGER NOT NULL,
lesson_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
started_by INTEGER,
started_at DATETIME,
completed_by INTEGER,
completed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
)
`);
db.exec(
'INSERT INTO attendance_sessions_new SELECT * FROM attendance_sessions',
);
db.exec('DROP TABLE attendance_sessions');
db.exec(
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
);
db.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
);
// Rebuild attendance_records with FK on attendance_session_id
const recordsFk = db
.prepare("PRAGMA foreign_key_list('attendance_records')")
.all() as Array<{ from: string }>;
const hasSessionFk = recordsFk.some((r) => r.from === 'attendance_session_id');
if (!hasSessionFk) {
db.exec(`
CREATE TABLE attendance_records_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
class_id INTEGER,
schedule_id INTEGER,
attendance_session_id INTEGER,
attendance_date DATE NOT NULL,
session VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
remark VARCHAR(200),
source VARCHAR(20) DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL
)
`);
db.exec(`
INSERT INTO attendance_records_new (
id, student_id, class_id, schedule_id, attendance_session_id,
attendance_date, session, status, remark, source, created_at, updated_at
)
SELECT
id, student_id, class_id, schedule_id, attendance_session_id,
attendance_date, session, status, remark, source, created_at, updated_at
FROM attendance_records
`);
db.exec('DROP TABLE attendance_records');
db.exec(
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
);
db.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
);
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
} finally {
db.exec('PRAGMA foreign_keys = ON');
}
// Run foreign_key_check — should be clean
const checkRows = db.prepare('PRAGMA foreign_key_check').all();
if (checkRows.length > 0) {
throw new Error(
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
);
}
}
beforeEach(() => {
db = new Database(':memory:');
createPreMigrationSchema();
});
afterEach(() => {
db.close();
});
it('preserves attendance_record.session_id after migration', () => {
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
db.exec(
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
);
db.exec(
"INSERT INTO attendance_records (id, student_id, class_id, attendance_session_id, attendance_date, session, status) VALUES (1, 1, 1, 1, '2026-01-01', 'morning', 'present')",
);
// Verify pre-migration state
const preSessionFk = db
.prepare("PRAGMA foreign_key_list('attendance_sessions')")
.all();
expect(preSessionFk.length).toBe(0);
const preRecordsFk = db
.prepare("PRAGMA foreign_key_list('attendance_records')")
.all();
expect(preRecordsFk.length).toBe(0);
// Run migration
runMigration();
// Verify attendance_record still has correct attendance_session_id
const record = db
.prepare('SELECT * FROM attendance_records WHERE id = 1')
.get() as Record<string, unknown>;
expect(record).toBeDefined();
expect(record.attendance_session_id).toBe(1);
expect(record.attendance_date).toBe('2026-01-01');
expect(record.session).toBe('morning');
expect(record.status).toBe('present');
// Verify FKs now exist on both tables
const postSessionFk = db
.prepare("PRAGMA foreign_key_list('attendance_sessions')")
.all();
expect(postSessionFk.length).toBe(2);
const postRecordsFk = db
.prepare("PRAGMA foreign_key_list('attendance_records')")
.all() as Array<{ from: string; table: string; on_delete: string }>;
const sessionFk = postRecordsFk.find((r) => r.from === 'attendance_session_id');
expect(sessionFk).toBeDefined();
expect(sessionFk!.table).toBe('attendance_sessions');
expect(sessionFk!.on_delete).toBe('SET NULL');
// RESTRICT still blocks class/schedule deletion
expect(() => {
db.exec('DELETE FROM classes WHERE id = 1');
}).toThrow();
expect(() => {
db.exec('DELETE FROM class_schedule WHERE id = 1');
}).toThrow();
// Verify data survived the failed deletes
const sessionAfter = db
.prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE id = 1')
.get() as { cnt: number };
expect(sessionAfter.cnt).toBe(1);
const recordAfter = db
.prepare('SELECT COUNT(*) as cnt FROM attendance_records WHERE id = 1')
.get() as { cnt: number };
expect(recordAfter.cnt).toBe(1);
const classAfter = db
.prepare('SELECT COUNT(*) as cnt FROM classes WHERE id = 1')
.get() as { cnt: number };
expect(classAfter.cnt).toBe(1);
});
});

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { DataSource, QueryRunner } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
@Injectable()
@@ -10,8 +10,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
async onApplicationBootstrap(): Promise<void> {
await this.ensureAiConfigTable();
await this.ensureCourseAttendanceSchema();
await this.backfillOrganizations();
await this.normalizeClassDates();
await this.protectAttendanceHistory();
}
private async ensureAiConfigTable(): Promise<void> {
@@ -100,6 +102,70 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
}
}
private async ensureCourseAttendanceSchema(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['attendance_records', 'attendance_sessions']);
const tableNames = new Set(tables.map((table) => table.name));
const isMySQL = this.dataSource.options.type === 'mysql';
if (!tableNames.has('attendance_sessions')) {
const pkDef = isMySQL
? 'id INTEGER PRIMARY KEY AUTO_INCREMENT'
: 'id INTEGER PRIMARY KEY AUTOINCREMENT';
await runner.query(`
CREATE TABLE attendance_sessions (
${pkDef},
schedule_id INTEGER NOT NULL,
class_id INTEGER NOT NULL,
lesson_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
started_by INTEGER,
started_at DATETIME,
completed_by INTEGER,
completed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
)
`);
}
const attendanceTable = await runner.getTable('attendance_records');
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
if (!columnNames.has('schedule_id')) {
await runner.query('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER');
}
if (!columnNames.has('attendance_session_id')) {
await runner.query(
'ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER',
);
}
const createIndex = async (sql: string) => {
try {
await runner.query(sql);
} catch {
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
}
};
await createIndex(
isMySQL
? 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)'
: 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
);
await createIndex(
isMySQL
? 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)'
: 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
);
} finally {
await runner.release();
}
}
private async backfillOrganizations(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
@@ -246,4 +312,191 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows;
if (affected) this.logger.log(`已规范化 ${affected} 条班级日期数据`);
}
private async protectAttendanceHistory(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['attendance_sessions']);
if (tables.length === 0) return;
const isMySQL = this.dataSource.options.type === 'mysql';
if (isMySQL) {
await this.migrateMySQLAttendanceFKs(runner);
} else {
await this.migrateSQLiteAttendanceFKs(runner);
}
} finally {
await runner.release();
}
}
private async migrateMySQLAttendanceFKs(runner: QueryRunner): Promise<void> {
// Drop any existing FK constraint on schedule_id or class_id
const fkColumns = ['schedule_id', 'class_id'];
for (const col of fkColumns) {
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(`
SELECT CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'attendance_sessions'
AND COLUMN_NAME = ?
AND REFERENCED_TABLE_NAME IS NOT NULL
`, [col]);
for (const row of fkRows) {
try {
await runner.query(
`ALTER TABLE attendance_sessions DROP FOREIGN KEY \`${row.CONSTRAINT_NAME}\``,
);
this.logger.log(`已移除考勤场次 FK 约束: ${row.CONSTRAINT_NAME}`);
} catch {
// constraint may have already been dropped
}
}
}
const constraints: Array<{ name: string; col: string; ref: string }> = [
{ name: 'fk_as_schedule_protect', col: 'schedule_id', ref: 'class_schedule(id)' },
{ name: 'fk_as_class_protect', col: 'class_id', ref: 'classes(id)' },
];
for (const c of constraints) {
// Only skip if RESTRICT constraint is already confirmed via information_schema
const existing: Array<{ DELETE_RULE: string }> = await runner.query(`
SELECT DELETE_RULE
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'attendance_sessions'
AND CONSTRAINT_NAME = ?
`, [c.name]);
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
continue;
}
// ADD RESTRICT must throw on failure — no catch
await runner.query(`
ALTER TABLE attendance_sessions
ADD CONSTRAINT ${c.name}
FOREIGN KEY (${c.col}) REFERENCES ${c.ref}
ON DELETE RESTRICT
`);
this.logger.log(`已添加考勤场次删除保护约束: ${c.name}`);
}
}
private async migrateSQLiteAttendanceFKs(runner: QueryRunner): Promise<void> {
// SQLite cannot ALTER TABLE to add foreign keys.
// Rebuild the table inside a transaction: create a new table with FK constraints,
// copy all rows, drop old, rename new, then recreate indexes.
const fkRows: Array<{ id: number }> = await runner.query(
"PRAGMA foreign_key_list('attendance_sessions')",
);
if (fkRows.length > 0) return; // FKs already present
this.logger.log('正在重建 attendance_sessions 表以添加外键保护…');
// PRAGMA foreign_keys=OFF must be issued outside the transaction
await runner.query('PRAGMA foreign_keys = OFF');
try {
await runner.query('BEGIN');
try {
await runner.query(`
CREATE TABLE attendance_sessions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL,
class_id INTEGER NOT NULL,
lesson_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
started_by INTEGER,
started_at DATETIME,
completed_by INTEGER,
completed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
)
`);
await runner.query(`
INSERT INTO attendance_sessions_new (
id, schedule_id, class_id, lesson_date, status,
started_by, started_at, completed_by, completed_at, created_at, updated_at
)
SELECT
id, schedule_id, class_id, lesson_date, status,
started_by, started_at, completed_by, completed_at, created_at, updated_at
FROM attendance_sessions
`);
await runner.query('DROP TABLE attendance_sessions');
await runner.query(
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
);
await runner.query(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
);
// Rebuild attendance_records to add/protect FK on attendance_session_id
const recordsFk = await runner.query(
"PRAGMA foreign_key_list('attendance_records')",
);
const hasSessionFk = recordsFk.some(
(r: { from: string }) => r.from === 'attendance_session_id',
);
if (!hasSessionFk) {
await runner.query(`
CREATE TABLE attendance_records_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
class_id INTEGER,
schedule_id INTEGER,
attendance_session_id INTEGER,
attendance_date DATE NOT NULL,
session VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
remark VARCHAR(200),
source VARCHAR(20) DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL
)
`);
await runner.query(`
INSERT INTO attendance_records_new (
id, student_id, class_id, schedule_id, attendance_session_id,
attendance_date, session, status, remark, source, created_at, updated_at
)
SELECT
id, student_id, class_id, schedule_id, attendance_session_id,
attendance_date, session, status, remark, source, created_at, updated_at
FROM attendance_records
`);
await runner.query('DROP TABLE attendance_records');
await runner.query(
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
);
await runner.query(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
);
}
// Verify foreign key integrity BEFORE committing the transaction.
// If violations exist, the transaction rolls back and old tables are preserved.
const checkRows = await runner.query('PRAGMA foreign_key_check');
if (checkRows.length > 0) {
throw new Error(
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
);
}
await runner.query('COMMIT');
this.logger.log('attendance_sessions 表外键保护重建完成');
} catch (err) {
await runner.query('ROLLBACK');
throw err;
}
} finally {
await runner.query('PRAGMA foreign_keys = ON');
}
}
}

View File

@@ -18,7 +18,7 @@ function mockRunner(overrides: {
} = {}) {
const release = jest.fn();
const connect = jest.fn();
const query = jest.fn();
const query = jest.fn().mockResolvedValue([]);
const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
const getTable = jest.fn().mockResolvedValue(
overrides.getTable ?? { name: 'ai_config', columns: [] },
@@ -31,19 +31,21 @@ function mockRunner(overrides: {
return { release, connect, query, getTables, getTable };
}
function createDataSource(runner: ReturnType<typeof mockRunner>) {
function createDataSource(runner: ReturnType<typeof mockRunner>, dbType: string = 'better-sqlite3') {
return {
options: { type: 'better-sqlite3' },
options: { type: dbType },
createQueryRunner: jest.fn().mockReturnValue(runner),
transaction: jest.fn(),
};
}
// Type to reach the private ensureAiConfigTable for testing
// Type to reach private migration methods for testing
interface MigrationsPrivate {
ensureAiConfigTable(): Promise<void>;
backfillOrganizations(): Promise<void>;
normalizeClassDates(): Promise<void>;
ensureCourseAttendanceSchema(): Promise<void>;
protectAttendanceHistory(): Promise<void>;
}
describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
@@ -176,3 +178,251 @@ describe('DatabaseMigrationsService — bootstrap failure handling', () => {
expect(normalize).not.toHaveBeenCalled();
});
});
describe('DatabaseMigrationsService — course attendance schema', () => {
it('adds schedule linkage columns to an existing attendance_records table', async () => {
const runner = mockRunner({
getTables: [
{ name: 'attendance_records', columns: [{ name: 'id' }] },
{ name: 'attendance_sessions', columns: [{ name: 'id' }] },
],
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
});
await bootstrapCourseAttendance(runner);
await service.ensureCourseAttendanceSchema();
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'),
);
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER'),
);
expect(runner.release).toHaveBeenCalled();
});
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
});
await bootstrapCourseAttendance(runner);
await service.ensureCourseAttendanceSchema();
const createSql: string = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''))
.find((s: string) => s.includes('CREATE TABLE attendance_sessions')) ?? '';
expect(createSql).toContain('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT');
expect(createSql).toContain('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT');
});
});
describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
let service: MigrationsPrivate & DatabaseMigrationsService;
async function bootstrap(runner: ReturnType<typeof mockRunner>, dbType: string = 'better-sqlite3') {
const dataSource = createDataSource(runner, dbType);
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate;
}
it('skips when attendance_sessions table is absent', async () => {
const runner = mockRunner({ getTables: [] });
await bootstrap(runner);
await service.protectAttendanceHistory();
expect(runner.query).not.toHaveBeenCalled();
expect(runner.release).toHaveBeenCalled();
});
it('SQLite: exits early when FKs already exist', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
runner.query.mockResolvedValueOnce([{ id: 0 }]); // PRAGMA foreign_key_list returns rows
await bootstrap(runner);
await service.protectAttendanceHistory();
// Should not run any TABLE creation (rebuild)
const queries: string[] = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0);
expect(runner.release).toHaveBeenCalled();
});
it('SQLite: rebuilds table with FK constraints when FKs are absent', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
// PRAGMA foreign_key_list for attendance_sessions → empty
runner.query.mockResolvedValueOnce([]);
// PRAGMA foreign_key_list for attendance_records → also empty (no FK yet)
runner.query.mockResolvedValueOnce([]);
await bootstrap(runner);
await service.protectAttendanceHistory();
const queries: string[] = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
// PRAGMA foreign_keys = OFF outside the transaction
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = OFF'))).toBe(true);
expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe(true);
expect(queries.some((q: string) =>
q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT')
)).toBe(true);
expect(queries.some((q: string) =>
q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT')
)).toBe(true);
expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe(true);
expect(queries.some((q: string) => q.includes('DROP TABLE attendance_sessions'))).toBe(true);
expect(queries.some((q: string) => q.includes('RENAME TO attendance_sessions'))).toBe(true);
expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe(true);
// attendance_records rebuilt with FK
expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe(true);
expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe(true);
expect(queries.some((q: string) => q.includes('DROP TABLE attendance_records'))).toBe(true);
expect(queries.some((q: string) => q.includes('uq_attendance_session_student'))).toBe(true);
// PRAGMA foreign_keys restored to ON and foreign_key_check runs
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true);
expect(queries.some((q: string) => q.includes('PRAGMA foreign_key_check'))).toBe(true);
expect(runner.release).toHaveBeenCalled();
});
it('SQLite: rolls back transaction when foreign_key_check finds violations', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
// Use mockImplementation to match by SQL content, not call position
runner.query.mockImplementation((sql: string) => {
if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_list')) {
return Promise.resolve([]); // FKs absent → trigger rebuild
}
if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_check')) {
return Promise.resolve([
{ table: 'attendance_sessions', rowid: 42, parent: 'class_schedule', fkid: 0 },
]);
}
return Promise.resolve([]);
});
await bootstrap(runner);
await expect(service.protectAttendanceHistory()).rejects.toThrow(
/外键一致性检查失败/,
);
const queries: string[] = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
// The transaction should have been rolled back (ROLLBACK called)
expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true);
// COMMIT should NOT have been called
expect(queries.some((q: string) => q.trim() === 'COMMIT')).toBe(false);
// PRAGMA foreign_keys should still be restored
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true);
expect(runner.release).toHaveBeenCalled();
});
it('MySQL: drops old FKs and recreates both schedule_id and class_id as RESTRICT', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
// Mock: override SELECT CONSTRAINT_NAME and REFERENTIAL_CONSTRAINTS queries
runner.query.mockImplementation((sql: string, params?: string[]) => {
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
if (params?.[0] === 'schedule_id') {
return Promise.resolve([{ CONSTRAINT_NAME: 'fk_schedule_cascade' }]);
}
if (params?.[0] === 'class_id') {
return Promise.resolve([{ CONSTRAINT_NAME: 'fk_class_cascade' }]);
}
}
// REFERENTIAL_CONSTRAINTS check — constraint does not yet exist
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
return Promise.resolve([]);
}
return Promise.resolve([]);
});
await bootstrap(runner, 'mysql');
await service.protectAttendanceHistory();
const queries: string[] = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
// Drops old FKs
expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_schedule_cascade`'))).toBe(true);
expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe(true);
// Checks REFERENTIAL_CONSTRAINTS before ADD
expect(queries.some((q: string) =>
q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')
)).toBe(true);
// Creates new RESTRICT FKs
expect(queries.some((q: string) =>
q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT')
)).toBe(true);
expect(queries.some((q: string) =>
q.includes('ADD CONSTRAINT fk_as_class_protect') && q.includes('ON DELETE RESTRICT')
)).toBe(true);
expect(runner.release).toHaveBeenCalled();
});
it('MySQL: throws when ADD CONSTRAINT RESTRICT fails', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
const addError = new Error('Cannot add foreign key constraint');
runner.query.mockImplementation((sql: string, params?: string[]) => {
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
return Promise.resolve([]);
}
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
return Promise.resolve([]);
}
if (typeof sql === 'string' && sql.includes('ADD CONSTRAINT')) {
return Promise.reject(addError);
}
return Promise.resolve([]);
});
await bootstrap(runner, 'mysql');
await expect(service.protectAttendanceHistory()).rejects.toThrow('Cannot add foreign key constraint');
expect(runner.release).toHaveBeenCalled();
});
it('MySQL: skips ADD when RESTRICT constraint already confirmed via information_schema', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
runner.query.mockImplementation((sql: string, params?: string[]) => {
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
return Promise.resolve([]);
}
// REFERENTIAL_CONSTRAINTS confirms RESTRICT already present
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
return Promise.resolve([{ DELETE_RULE: 'RESTRICT' }]);
}
return Promise.resolve([]);
});
await bootstrap(runner, 'mysql');
await service.protectAttendanceHistory();
const queries: string[] = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
// No ADD CONSTRAINT calls
expect(queries.filter((q: string) => q.includes('ADD CONSTRAINT')).length).toBe(0);
expect(runner.release).toHaveBeenCalled();
});
});
async function bootstrapCourseAttendance(runner: ReturnType<typeof mockRunner>) {
const dataSource = createDataSource(runner);
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate;
}

View File

@@ -47,12 +47,6 @@ export class DepositsController {
});
}
@Get('pending-refunds')
@RequirePermission('deposit:edit')
findPendingRefunds() {
return this.service.findPendingRefunds();
}
@Get('stats')
@RequirePermission('deposit:view')
getStats() {
@@ -165,7 +159,7 @@ export class DepositsController {
}
@Put(':id/refund')
@RequirePermission('deposit:edit')
@RequirePermission('deposit:refund')
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.refund(+id, dto, req.user?.id);
@@ -195,67 +189,6 @@ export class DepositsController {
return result;
}
@Post(':id/request-refund')
@RequirePermission('deposit:edit')
async requestRefund(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.requestRefund(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '申请退款',
targetId: +id,
targetType: 'deposit',
detail: '提交退款申请',
ipAddress,
userAgent,
});
return result;
}
@Put(':id/approve-refund')
@RequirePermission('deposit:approve')
async approveRefund(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.approveRefund(+id, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '审批退款',
targetId: +id,
targetType: 'deposit',
detail: `审批通过 → ${result.refundStatus}`,
ipAddress,
userAgent,
});
return result;
}
@Put(':id/reject-refund')
@RequirePermission('deposit:approve')
async rejectRefund(
@Param('id') id: string,
@Body() body: { reason: string },
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.rejectRefund(+id, body.reason, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '驳回退款',
targetId: +id,
targetType: 'deposit',
detail: `驳回原因:${body.reason}`,
ipAddress,
userAgent,
});
return result;
}
@Delete(':id')
@RequirePermission('deposit:delete')
async remove(@Param('id') id: string, @Request() req: any) {

View File

@@ -114,79 +114,13 @@ export class DepositsService {
deposit.status =
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
if (dto.notes) deposit.notes = dto.notes;
return this.repo.save(deposit);
}
// ---- Refund approval flow ----
async requestRefund(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (deposit.status !== 'paid') throw new BadRequestException('该押金已处理');
if (deposit.refundStatus) throw new BadRequestException('已提交退款申请,请等待审批');
deposit.refundStatus = 'pending';
deposit.refundRequestedAt = new Date();
return this.repo.save(deposit);
}
async approveRefund(id: number, userId: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (!deposit.refundStatus || deposit.refundStatus === 'refunded') {
throw new BadRequestException('未找到待审批的退款申请');
}
const transitions: Record<string, string> = {
pending: 'head_teacher_approved',
head_teacher_approved: 'finance_approved',
finance_approved: 'refunded',
};
const nextStatus = transitions[deposit.refundStatus];
if (!nextStatus) throw new BadRequestException(`无效的退款状态: ${deposit.refundStatus}`);
deposit.refundStatus = nextStatus;
deposit.refundApprovedBy = userId;
deposit.refundStatus = 'refunded';
deposit.refundApprovedBy = userId ?? null as unknown as number;
deposit.refundApprovedAt = new Date();
if (nextStatus === 'refunded') {
deposit.status = 'refunded';
deposit.refundDate = new Date().toISOString().slice(0, 10);
deposit.refundAmount = Number(deposit.amount) - Number(deposit.deductionAmount || 0);
}
return this.repo.save(deposit);
}
async rejectRefund(id: number, reason: string, userId: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (!deposit.refundStatus || deposit.refundStatus === 'refunded') {
throw new BadRequestException('未找到待审批的退款申请');
}
deposit.refundStatus = null as unknown as string;
deposit.refundApprovedBy = userId;
deposit.refundApprovedAt = new Date();
deposit.refundRejectedReason = reason;
return this.repo.save(deposit);
}
async findPendingRefunds() {
return this.repo.find({
where: [
{ refundStatus: 'pending' },
{ refundStatus: 'head_teacher_approved' },
],
relations: ['student', 'installments'],
order: { refundRequestedAt: 'DESC' },
});
}
async remove(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');

View File

@@ -10,10 +10,13 @@ import {
} from 'typeorm';
import { Student } from './student.entity';
import { Class } from './class.entity';
import { ClassSchedule } from './class-schedule.entity';
import { AttendanceSession } from './attendance-session.entity';
@Entity('attendance_records')
@Index(['classId', 'attendanceDate'])
@Index(['studentId', 'attendanceDate'])
@Index(['attendanceSessionId', 'studentId'], { unique: true })
export class AttendanceRecord {
@PrimaryGeneratedColumn()
id: number;
@@ -32,6 +35,23 @@ export class AttendanceRecord {
@JoinColumn({ name: 'class_id' })
class: Class;
@Column({ name: 'schedule_id', type: 'integer', nullable: true })
scheduleId: number | null;
@ManyToOne(() => ClassSchedule, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'schedule_id' })
schedule: ClassSchedule | null;
@Column({ name: 'attendance_session_id', type: 'integer', nullable: true })
attendanceSessionId: number | null;
@ManyToOne(() => AttendanceSession, (session) => session.records, {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'attendance_session_id' })
attendanceSession: AttendanceSession | null;
@Column({ name: 'attendance_date', type: 'date' })
attendanceDate: string;
@@ -41,8 +61,8 @@ export class AttendanceRecord {
@Column({ length: 20 })
status: string;
@Column({ length: 200, nullable: true })
remark: string;
@Column({ type: 'varchar', length: 200, nullable: true })
remark: string | null;
@Column({ name: 'source', length: 20, default: 'manual' })
source: string;

View File

@@ -0,0 +1,62 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { ClassSchedule } from './class-schedule.entity';
import { Class } from './class.entity';
import { AttendanceRecord } from './attendance-record.entity';
@Entity('attendance_sessions')
@Index(['scheduleId', 'lessonDate'], { unique: true })
export class AttendanceSession {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'schedule_id', type: 'integer' })
scheduleId: number;
@ManyToOne(() => ClassSchedule, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'schedule_id' })
schedule: ClassSchedule;
@Column({ name: 'class_id', type: 'integer' })
classId: number;
@ManyToOne(() => Class, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'class_id' })
class: Class;
@Column({ name: 'lesson_date', type: 'date' })
lessonDate: string;
@Column({ length: 20, default: 'in_progress' })
status: string;
@Column({ name: 'started_by', type: 'integer', nullable: true })
startedBy: number | null;
@Column({ name: 'started_at', type: 'datetime', nullable: true })
startedAt: Date | null;
@Column({ name: 'completed_by', type: 'integer', nullable: true })
completedBy: number | null;
@Column({ name: 'completed_at', type: 'datetime', nullable: true })
completedAt: Date | null;
@OneToMany(() => AttendanceRecord, (record) => record.attendanceSession)
records: AttendanceRecord[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -21,6 +21,7 @@ export { ClassStudent } from './class-student.entity';
export { ClassTeacher, TeacherRoleType } from './class-teacher.entity';
export { ClassSchedule, ScheduleType } from './class-schedule.entity';
export { AttendanceRecord } from './attendance-record.entity';
export { AttendanceSession } from './attendance-session.entity';
export { DingAttendanceRaw } from './ding-attendance-raw.entity';
export { SyncLog } from './sync-log.entity';
export { SyncState } from './sync-state.entity';

View File

@@ -0,0 +1,53 @@
import { DingTalkService } from './dingtalk.service';
describe('DingTalkService attendance group deletion', () => {
const originalAppKey = process.env.DINGTALK_APP_KEY;
const originalAppSecret = process.env.DINGTALK_APP_SECRET;
let service: DingTalkService;
beforeEach(() => {
process.env.DINGTALK_APP_KEY = 'test-app-key';
process.env.DINGTALK_APP_SECRET = 'test-app-secret';
service = new DingTalkService({} as never, {} as never);
Object.assign(service, {
accessToken: 'test-token',
tokenExpiresAt: Date.now() + 3_600_000,
});
});
afterEach(() => {
jest.restoreAllMocks();
global.fetch = undefined as unknown as typeof fetch;
});
afterAll(() => {
if (originalAppKey === undefined) delete process.env.DINGTALK_APP_KEY;
else process.env.DINGTALK_APP_KEY = originalAppKey;
if (originalAppSecret === undefined) delete process.env.DINGTALK_APP_SECRET;
else process.env.DINGTALK_APP_SECRET = originalAppSecret;
});
it('converts groupId to groupKey before deleting the group', async () => {
global.fetch = jest
.fn()
.mockResolvedValueOnce({
json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', result: 'group-key-1' }),
})
.mockResolvedValueOnce({
json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', success: true }),
}) as jest.MockedFunction<typeof fetch>;
await service.deleteAttendanceGroup(123, 'manager');
expect(global.fetch).toHaveBeenNthCalledWith(
1,
expect.stringContaining('/topapi/attendance/groups/idtokey'),
expect.objectContaining({ body: JSON.stringify({ op_user_id: 'manager', group_id: 123 }) }),
);
expect(global.fetch).toHaveBeenNthCalledWith(
2,
expect.stringContaining('/topapi/attendance/group/delete'),
expect.objectContaining({ body: JSON.stringify({ op_userid: 'manager', group_key: 'group-key-1' }) }),
);
});
});

View File

@@ -480,7 +480,7 @@ export class DingTalkService {
return records.map((r) => ({
userId: r.userId,
userName: '',
workDate: new Date(r.workDate).toISOString().slice(0, 10),
workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10),
timeResult: r.timeResult ?? r.sourceType ?? '',
locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '',
planCheckTime: '',
@@ -728,6 +728,47 @@ export class DingTalkService {
return all;
}
async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> {
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
const token = await this.getAccessToken();
await this.rateLimit();
const keyResponse = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }),
},
);
const keyData = await keyResponse.json() as {
errcode: number;
errmsg: string;
result?: string;
};
if (keyData.errcode !== 0 || !keyData.result) {
throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`);
}
await this.rateLimit();
const deleteResponse = await fetch(
`https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }),
},
);
const deleteData = await deleteResponse.json() as {
errcode: number;
errmsg: string;
success?: boolean;
};
if (deleteData.errcode !== 0 || deleteData.success !== true) {
throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`);
}
}
// ═══════════════════════════════════════════
// 考勤排班 — 排班分配

View File

@@ -126,7 +126,7 @@ export class RbacController {
// ==================== 用户管理 ====================
@Get('users')
@RequirePermission('user:view')
@RequirePermission('user:view', 'teacher:view')
getUsers(@Query('isArchived') isArchived?: string) {
const archived = isArchived === 'true';
return this.rbacService.findAllUsers(archived);
@@ -301,7 +301,7 @@ export class RbacController {
// ---- 教师工作台 ----
@Get('teacher-workspace')
@RequirePermission('class:view')
@RequirePermission('teacher-workspace:view')
async getTeacherWorkspace(@Request() req: any) {
return this.rbacService.getTeacherWorkspace(req.user?.id);
}
@@ -309,7 +309,7 @@ export class RbacController {
// ---- 教师管理 ----
@Get('teachers')
@RequirePermission('user:view')
@RequirePermission('teacher:view')
async getTeachers(
@Query('search') search?: string,
@Query('page') page?: string,
@@ -323,7 +323,7 @@ export class RbacController {
}
@Put('teachers/:id/profile')
@RequirePermission('user:edit')
@RequirePermission('teacher:edit')
async updateTeacherProfile(
@Param('id') id: string,
@Body() profile: UpdateProfileDto,

View File

@@ -7,32 +7,59 @@ function permissionsFor(roleCode: string): { groups: string[]; extras: string[]
}
describe('preset role permissions', () => {
it('gives teachers explicit workspace permissions without class/schedule delete privileges', () => {
it('keeps teachers read-only in scheduling while preserving class attendance access', () => {
const teacher = permissionsFor('teacher');
expect(teacher.groups).toEqual(['notification', 'profile']);
expect(teacher.extras).toEqual(
expect.arrayContaining([
'student:view',
'class:view',
'teacher-workspace:view',
'schedule:view',
'attendance:view',
'attendance:create',
'attendance:export',
'attendance:self-edit',
]),
);
expect(teacher.extras).not.toEqual(
expect.arrayContaining(['class:delete', 'schedule:delete']),
expect(teacher.extras).not.toContain('schedule:create');
expect(teacher.extras).not.toContain('schedule:edit');
expect(teacher.extras).not.toContain('schedule:delete');
expect(teacher.extras).not.toContain('student:view');
expect(teacher.extras).not.toContain('class:view');
expect(teacher.extras).not.toContain('attendance:export');
});
it('gives academic administrators the complete teaching administration workflow', () => {
const academic = permissionsFor('academic');
expect(academic.groups).toEqual(
expect.arrayContaining(['student', 'class', 'schedule', 'attendance', 'classroom']),
);
expect(academic.extras).toEqual(expect.arrayContaining(['sync:read', 'sync:trigger']));
});
it('combines accommodation, expenses, bills and deposits in one operations role', () => {
const accommodation = permissionsFor('accommodation_operations');
expect(accommodation.groups).toEqual(
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
);
expect(accommodation.extras).toContain('student:basic-view');
});
it('keeps classroom rental operations separate from accommodation operations', () => {
const classroomOperations = permissionsFor('classroom_operations');
expect(classroomOperations.groups).toEqual(
expect.arrayContaining(['classroom', 'rental', 'organization']),
);
expect(classroomOperations.groups).not.toEqual(expect.arrayContaining(['room', 'deposit']));
});
it('limits system administrators to accounts, permissions, logs and integrations', () => {
const systemAdmin = permissionsFor('system_admin');
expect(systemAdmin.groups).toEqual(
expect.arrayContaining(['user', 'role', 'log', 'integration', 'sync', 'ai']),
);
expect(systemAdmin.groups).not.toEqual(
expect.arrayContaining(['student', 'schedule', 'attendance', 'expense']),
);
});
it('gives institution heads every read permission required by the classroom rental pages', () => {
const role = permissionsFor('institution_head');
expect(role.groups).toEqual(expect.arrayContaining(['classroom', 'rental', 'organization']));
});
it('keeps roles without dashboard access off the dashboard', () => {
expect(permissionsFor('teacher').groups).not.toContain('dashboard');
expect(permissionsFor('institution_head').groups).not.toContain('dashboard');
});
});

View File

@@ -1,7 +1,7 @@
import { RbacService } from './rbac.service';
describe('RbacService seedData', () => {
it('adds preset permissions to system roles without removing manually granted permissions', async () => {
it('migrates the legacy teacher role and replaces broad permissions with the teaching matrix', async () => {
const permissions = [
{ id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' },
{ id: 2, code: 'notification:view', name: '查看通知', group: 'notification' },
@@ -10,27 +10,32 @@ describe('RbacService seedData', () => {
{ id: 5, code: 'schedule:view', name: '查看排课', group: 'schedule' },
{ id: 6, code: 'attendance:view', name: '查看考勤', group: 'attendance' },
{ id: 7, code: 'attendance:create', name: '新增考勤', group: 'attendance' },
{ id: 8, code: 'attendance:export', name: '导出考勤', group: 'attendance' },
{ id: 8, code: 'teacher-workspace:view', name: '教师工作台', group: 'teacher-workspace' },
{ id: 9, code: 'room:view', name: '查看宿舍', group: 'room' },
{ id: 10, code: 'schedule:create', name: '新增排课', group: 'schedule' },
];
const teacherRole = {
id: 1,
name: '老师',
description: '查看和管理本班学生',
code: 'teacher',
description: '旧角色',
isSystem: true,
permissions: [permissions[8]],
status: 1,
permissions: [permissions[2], permissions[3], permissions[8]],
};
const permRepo = {
findOne: jest.fn(
async ({ where }: any) => permissions.find((p) => p.code === where.code) ?? null,
async ({ where }: any) => permissions.find((permission) => permission.code === where.code) ?? null,
),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
find: jest.fn(async () => permissions),
};
const roleRepo = {
findOne: jest.fn(async ({ where }: any) => (where.name === '老师' ? teacherRole : null)),
findOne: jest.fn(async ({ where }: any) =>
where.code === 'teacher' || where.name === '老师' ? teacherRole : null,
),
create: jest.fn((value) => ({ ...value, permissions: [] })),
save: jest.fn(async (value) => value),
find: jest.fn(async () => [teacherRole]),
@@ -50,8 +55,86 @@ describe('RbacService seedData', () => {
await service.seedData();
expect(teacherRole.name).toBe('任课老师');
expect(teacherRole.permissions.map((permission) => permission.code)).toEqual(
expect.arrayContaining(['room:view', 'profile:view', 'student:view', 'attendance:create']),
expect.arrayContaining([
'profile:view',
'teacher-workspace:view',
'schedule:view',
'attendance:create',
]),
);
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain(
'schedule:create',
);
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('student:view');
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('class:view');
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('room:view');
});
});
describe('RbacService legacy role consolidation', () => {
it('moves users from duplicate accommodation roles before deleting the duplicates', async () => {
const permissions = [
{ id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' },
{ id: 2, code: 'room:view', name: '查看宿舍', group: 'room' },
{ id: 3, code: 'expense:view', name: '查看费用', group: 'expense' },
{ id: 4, code: 'student:basic-view', name: '学生基础信息', group: 'student-scope' },
];
const targetRole: any = {
id: 10,
name: '住宿运营管理员',
code: 'accommodation_operations',
description: '',
isSystem: true,
status: 1,
permissions: [],
users: [],
};
const legacyRole: any = {
id: 11,
name: '财务',
code: 'finance',
description: '',
isSystem: true,
status: 1,
permissions: [],
users: [{ id: 21 }],
};
const user: any = { id: 21, roles: [legacyRole] };
const permRepo = {
findOne: jest.fn(async ({ where }: any) => permissions.find((item) => item.code === where.code) ?? null),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
find: jest.fn(async () => permissions),
};
const roleRepo = {
findOne: jest.fn(async () => targetRole),
create: jest.fn((value) => ({ ...value, permissions: [] })),
save: jest.fn(async (value) => value),
find: jest.fn(async () => [targetRole, legacyRole]),
remove: jest.fn(async (value) => value),
};
const userRepo = {
count: jest.fn(async () => 1),
findOne: jest.fn(async () => user),
save: jest.fn(async (value) => value),
};
const service = new RbacService(
permRepo as never,
roleRepo as never,
userRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
await service.seedData();
expect(user.roles).toEqual([targetRole]);
expect(userRepo.save).toHaveBeenCalledWith(user);
expect(roleRepo.remove).toHaveBeenCalledWith(legacyRole);
});
});

View File

@@ -17,7 +17,11 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
{ code: 'profile:view', name: '查看个人资料', group: 'profile' },
{ code: 'notification:view', name: '查看通知', group: 'notification' },
{ code: 'student:view', name: '查看学生', group: 'student' },
{ code: 'student:view', name: '查看学生管理', group: 'student' },
{ code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' },
{ code: 'teacher-workspace:view', name: '查看教师工作台', group: 'teacher-workspace' },
{ code: 'teacher:view', name: '查看教师', group: 'teacher' },
{ code: 'teacher:edit', name: '编辑教师', group: 'teacher' },
{ code: 'student:create', name: '新增学生', group: 'student' },
{ code: 'student:edit', name: '编辑学生', group: 'student' },
{ code: 'student:delete', name: '删除学生', group: 'student' },
@@ -46,7 +50,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'deposit:create', name: '新增押金', group: 'deposit' },
{ code: 'deposit:edit', name: '编辑押金', group: 'deposit' },
{ code: 'deposit:delete', name: '删除押金', group: 'deposit' },
{ code: 'deposit:approve', name: '审批退款', group: 'deposit' },
{ code: 'deposit:refund', name: '直接退还押金', group: 'deposit' },
{ code: 'classroom:view', name: '查看教室', group: 'classroom' },
{ code: 'classroom:create', name: '新增教室', group: 'classroom' },
{ code: 'classroom:edit', name: '编辑教室', group: 'classroom' },
@@ -80,7 +84,8 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'schedule:delete', name: '删除排课', group: 'schedule' },
{ code: 'attendance:view', name: '查看考勤', group: 'attendance' },
{ code: 'attendance:create', name: '新增考勤', group: 'attendance' },
{ code: 'attendance:edit', name: '编辑考勤', group: 'attendance' },
{ code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' },
{ code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' },
{ code: 'attendance:export', name: '导出考勤', group: 'attendance' },
{ code: 'attendance:generate', name: '按课表生成考勤', group: 'attendance' },
{ code: 'learning:create', name: '创建学习任务', group: 'learning' },
@@ -108,85 +113,39 @@ export const PRESET_ROLES: Array<{
isSystem: boolean;
permissionGroups: string[];
extraPermissions?: string[];
legacyNames?: string[];
legacyCodes?: string[];
}> = [
{
name: '超',
name: '超级管理员',
code: 'super_admin',
description: '系统超级管理员,拥有全部权限',
description: '系统初始化、应急维护和全局权限处理',
isSystem: true,
permissionGroups: [],
legacyNames: ['超管', 'super_admin'],
},
{
name: '宿管老师',
code: 'dormitory_supervisor',
description: '管理宿舍相关业务',
isSystem: true,
permissionGroups: [
'student',
'room',
'occupancy',
'expense',
'bill',
'deposit',
'log',
'dashboard',
'class',
'schedule',
'attendance',
'notification',
'profile',
],
},
{
name: '老师',
name: '任课老师',
code: 'teacher',
description: '查看和管理本班学生',
description: '查看自己的排课、今日课程和任教班级考勤',
isSystem: true,
permissionGroups: ['notification', 'profile'],
extraPermissions: [
'student:view',
'class:view',
'teacher-workspace:view',
'schedule:view',
'attendance:view',
'attendance:create',
'attendance:export',
'attendance:self-edit',
],
legacyNames: ['老师'],
},
{
name: '机构负责人',
code: 'institution_head',
description: '管理机构教室和课程',
isSystem: true,
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'],
},
{
name: '财务',
code: 'finance',
description: '管理费用、账单与押金',
isSystem: true,
permissionGroups: ['expense', 'bill', 'deposit', 'dashboard', 'notification', 'profile'],
},
{
name: '宿管',
code: 'dorm_manager',
description: '管理宿舍入住与宿舍信息',
name: '教务管理员',
code: 'academic',
description: '管理学生、班级、教师、全局排课和历史考勤',
isSystem: true,
permissionGroups: [
'student',
'room',
'occupancy',
'deposit',
'dashboard',
'notification',
'profile',
],
},
{
name: '教务',
code: 'academic',
description: '管理班级、排课、考勤、学习与考试',
isSystem: true,
permissionGroups: [
'class',
'schedule',
'attendance',
@@ -197,6 +156,59 @@ export const PRESET_ROLES: Array<{
'notification',
'profile',
],
extraPermissions: [
'teacher-workspace:view',
'teacher:view',
'teacher:edit',
'sync:read',
'sync:trigger',
],
legacyNames: ['教务'],
},
{
name: '住宿运营管理员',
code: 'accommodation_operations',
description: '管理宿舍、入住、住宿费用、账单、押金和退宿结算',
isSystem: true,
permissionGroups: [
'room',
'occupancy',
'expense',
'bill',
'deposit',
'dashboard',
'notification',
'profile',
],
extraPermissions: ['student:basic-view'],
legacyNames: ['宿管老师', '宿管', '财务'],
legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'],
},
{
name: '教室运营管理员',
code: 'classroom_operations',
description: '管理教室、教室排期、外部机构和租赁订单',
isSystem: true,
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'],
legacyNames: ['机构负责人'],
legacyCodes: ['institution_head'],
},
{
name: '系统管理员',
code: 'system_admin',
description: '管理账号、角色、日志、同步和系统配置',
isSystem: true,
permissionGroups: [
'user',
'role',
'log',
'integration',
'sync',
'ai',
'department',
'notification',
'profile',
],
},
];
@@ -215,6 +227,18 @@ export class RbacService {
@InjectRepository(Student) private studentRepo: Repository<Student>,
) {}
private async findLegacyPresetRole(preset: (typeof PRESET_ROLES)[number]): Promise<Role | null> {
for (const code of preset.legacyCodes ?? []) {
const role = await this.roleRepo.findOne({ where: { code } });
if (role) return role;
}
for (const name of preset.legacyNames ?? []) {
const role = await this.roleRepo.findOne({ where: { name } });
if (role) return role;
}
return null;
}
async seedData(): Promise<void> {
// Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL
for (const p of PRESET_PERMISSIONS) {
@@ -227,7 +251,10 @@ export class RbacService {
// Step 2: 幂等插入预置角色
for (const r of PRESET_ROLES) {
const exists = await this.roleRepo.findOne({ where: { name: r.name } });
const exists =
(await this.roleRepo.findOne({ where: { code: r.code } })) ||
(await this.roleRepo.findOne({ where: { name: r.name } })) ||
(await this.findLegacyPresetRole(r));
if (!exists) {
await this.roleRepo.save(
this.roleRepo.create({
@@ -239,14 +266,44 @@ export class RbacService {
);
}
}
const allRoles = await this.roleRepo.find({ relations: ['permissions'] });
const allRoles = await this.roleRepo.find({ relations: ['permissions', 'users'] });
// Step 3: 构建角色-权限关联
// Step 3: 合并旧角色并构建新的职责权限矩阵
for (const preset of PRESET_ROLES) {
const role = allRoles.find((r) => r.name === preset.name || r.code === preset.code);
const matchesPreset = (role: Role) =>
role.name === preset.name ||
role.code === preset.code ||
preset.legacyNames?.includes(role.name) ||
preset.legacyCodes?.includes(role.code);
const candidates = allRoles.filter(matchesPreset);
const role = candidates.find((candidate) => candidate.code === preset.code) ?? candidates[0];
if (!role) continue;
if (role.code !== preset.code) {
const duplicateRoles = candidates.filter((candidate) => candidate.id !== role.id);
if (duplicateRoles.length > 0) {
for (const duplicate of duplicateRoles) {
for (const relatedUser of duplicate.users ?? []) {
const user = await this.userRepo.findOne({
where: { id: relatedUser.id },
relations: ['roles'],
});
if (!user) continue;
const remainingRoles = (user.roles ?? []).filter(
(assignedRole) => assignedRole.id !== duplicate.id && assignedRole.id !== role.id,
);
user.roles = [...remainingRoles, role];
await this.userRepo.save(user);
}
await this.roleRepo.remove(duplicate);
}
}
if (role.code !== preset.code || role.name !== preset.name || role.description !== preset.description) {
role.code = preset.code;
role.name = preset.name;
role.description = preset.description;
role.isSystem = preset.isSystem;
role.status = 1;
await this.roleRepo.save(role);
}
@@ -265,12 +322,11 @@ export class RbacService {
);
}
// 系统角色只补齐预置权限,不移除管理员手动授予的额外权限
// 这样新增权限(例如 profile:view会自动补上同时避免重启后覆盖人工配置。
const currentIds = new Set(role.permissions.map((permission) => permission.id));
const missingPerms = perms.filter((permission) => !currentIds.has(permission.id));
if (missingPerms.length > 0) {
role.permissions = [...role.permissions, ...missingPerms];
// 系统预置角色必须严格遵循职责矩阵;额外授权请创建自定义角色叠加
const currentIds = role.permissions.map((permission) => permission.id).sort((a, b) => a - b);
const targetIds = perms.map((permission) => permission.id).sort((a, b) => a - b);
if (currentIds.join(',') !== targetIds.join(',')) {
role.permissions = perms;
await this.roleRepo.save(role);
}
}
@@ -285,7 +341,7 @@ export class RbacService {
passwordHash: hash,
name: '管理员',
});
const superAdminRole = allRoles.find((r) => r.name === '超管');
const superAdminRole = allRoles.find((r) => r.code === 'super_admin');
if (superAdminRole) {
adminUser.roles = [superAdminRole];
}
@@ -555,7 +611,6 @@ export class RbacService {
.andWhere('cs.startDate <= :today', { today: todayStr })
.andWhere('cs.endDate >= :today', { today: todayStr })
.andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.teacherId = :userId', { userId })
.orderBy('cs.startTime', 'ASC')
.getMany();
@@ -594,8 +649,8 @@ export class RbacService {
async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) {
const page = query?.page || 1;
const pageSize = query?.pageSize || 20;
const teacherRoleCodes = ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'];
const teacherRoleNames = ['老师', '班主任', '宿管老师', '超管'];
const teacherRoleCodes = ['teacher', 'super_admin'];
const teacherRoleNames = ['任课老师', '老师', '超级管理员', '超管'];
const qb = this.userRepo
.createQueryBuilder('u')

View File

@@ -0,0 +1,54 @@
import { RbacService } from './rbac.service';
describe('RbacService getTeacherWorkspace', () => {
it('loads today schedules for every assigned class without requiring schedule.teacherId', async () => {
const queryBuilder = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([
{
id: 12,
classId: 8,
classroomId: 3,
teacherId: null,
weekDay: new Date().getDay() || 7,
startTime: '09:00',
endTime: '10:00',
subject: '数学',
scheduleType: 'INTERNAL',
},
]),
};
const classTeacherRepo = {
find: jest.fn().mockResolvedValue([
{
classId: 8,
userId: 21,
roleType: 'subject_teacher',
subject: '数学',
class: { id: 8, name: '一班', code: 'C001' },
},
]),
};
const classStudentRepo = { find: jest.fn().mockResolvedValue([]) };
const classScheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(queryBuilder) };
const service = new RbacService(
{} as never,
{} as never,
{} as never,
{} as never,
classStudentRepo as never,
classTeacherRepo as never,
classScheduleRepo as never,
{} as never,
);
const result = await service.getTeacherWorkspace(21);
expect(result.todaySchedules).toHaveLength(1);
expect(queryBuilder.andWhere).not.toHaveBeenCalledWith('cs.teacherId = :userId', {
userId: 21,
});
});
});

View File

@@ -0,0 +1,126 @@
import { ForbiddenException } from '@nestjs/common';
import { SchedulesController } from './schedules.controller';
import { SchedulesService } from './schedules.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { NotificationsService } from '../notifications/notifications.service';
const teacherRequest = {
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
headers: {},
};
const scheduleDto = {
classId: 8,
classroomId: 3,
weekDay: 1,
startTime: '09:00',
endTime: '10:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
subject: '数学',
};
describe('SchedulesController — class data scope', () => {
const service = {
getAccessibleClassIds: jest.fn(),
assertClassAccess: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
getClassroomOccupancy: jest.fn(),
maskScheduleOccupancy: jest.fn((schedule) => ({
...schedule,
id: null,
classId: null,
subject: '已占用',
teacherId: null,
notes: null,
canViewDetails: false,
})),
checkConflict: jest.fn(),
};
const logService = { log: jest.fn().mockResolvedValue(undefined) };
const notificationsService = { create: jest.fn() };
const ability = { can: jest.fn().mockReturnValue(false) };
const authzService = { abilityForRequest: jest.fn().mockReturnValue(ability) };
let controller: SchedulesController;
beforeEach(() => {
jest.clearAllMocks();
ability.can.mockReturnValue(false);
service.getAccessibleClassIds.mockResolvedValue([8]);
controller = new SchedulesController(
service as unknown as SchedulesService,
logService as unknown as OperationLogsService,
notificationsService as unknown as NotificationsService,
authzService as never,
);
});
it('checks the requested class before creating a schedule', async () => {
service.create.mockResolvedValue({ id: 1, ...scheduleDto });
await controller.create(scheduleDto, teacherRequest as never);
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
expect(service.create).toHaveBeenCalledWith(scheduleDto);
});
it('checks both the current and destination class before moving a schedule', async () => {
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
service.update.mockResolvedValue({ id: 4, ...scheduleDto, classId: 9 });
await controller.update('4', { classId: 9 }, teacherRequest as never);
expect(service.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
expect(service.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 9, false);
});
it('checks the owning class before returning full schedule details', async () => {
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
await controller.findOne('4', teacherRequest as never);
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
});
it('checks the owning class before deleting a schedule', async () => {
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
service.remove.mockResolvedValue({ success: true });
await controller.remove('4', teacherRequest as never);
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
expect(service.remove).toHaveBeenCalledWith(4);
});
it('returns only masked occupancy blocks from the classroom occupancy endpoint', async () => {
service.getClassroomOccupancy.mockResolvedValue([
{ id: 2, classId: 99, subject: '英语', teacherId: 7, notes: '隐私', classroomId: 3 },
]);
const result = await controller.getClassroomOccupancy('3', undefined, teacherRequest as never);
expect(result).toEqual([
expect.objectContaining({
id: null,
classId: null,
subject: '已占用',
teacherId: null,
notes: null,
canViewDetails: false,
}),
]);
expect(JSON.stringify(result)).not.toContain('英语');
expect(JSON.stringify(result)).not.toContain('隐私');
});
it('rejects records without a class instead of exposing full details to a scoped teacher', async () => {
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto, classId: null });
await expect(controller.findOne('4', teacherRequest as never)).rejects.toBeInstanceOf(
ForbiddenException,
);
});
});

View File

@@ -9,12 +9,10 @@ import {
Query,
UseGuards,
Request,
ForbiddenException,
ConflictException,
} from '@nestjs/common';
import {
AuthorizationService,
CaslAction,
SubjectName,
} from '../authorization';
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
import { SchedulesService } from './schedules.service';
import {
CreateScheduleDto,
@@ -25,7 +23,6 @@ import {
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { ConflictException } from '@nestjs/common';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@@ -56,6 +53,22 @@ export class SchedulesController {
);
}
private assertClassAccess(req: { user: RequestUser }, classId: number) {
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllSchedules(req));
}
private async getAuthorizedSchedule(id: number, req: { user: RequestUser }) {
const schedule = await this.service.findOne(id);
if (schedule.classId == null) {
if (!this.canManageAllSchedules(req)) {
throw new ForbiddenException('无权访问该排课详情');
}
return schedule;
}
await this.assertClassAccess(req, schedule.classId);
return schedule;
}
@Get('lookups')
@RequirePermission('schedule:view')
async getLookups(@Request() req: { user: RequestUser }) {
@@ -99,14 +112,29 @@ export class SchedulesController {
@Get('classroom/:id/occupancy')
@RequirePermission('schedule:view')
getClassroomOccupancy(@Param('id') id: string, @Query('date') date?: string) {
return this.service.getClassroomOccupancy(+id, date);
async getClassroomOccupancy(
@Param('id') id: string,
@Query('date') date: string | undefined,
@Request() req: { user: RequestUser },
) {
const schedules = await this.service.getClassroomOccupancy(+id, date);
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllSchedules(req),
);
if (!classIds) return schedules.map((schedule) => ({ ...schedule, canViewDetails: true }));
const allowed = new Set(classIds);
return schedules.map((schedule) =>
schedule.classId !== null && allowed.has(schedule.classId)
? { ...schedule, canViewDetails: true }
: this.service.maskScheduleOccupancy(schedule),
);
}
@Get(':id')
@RequirePermission('schedule:view')
findOne(@Param('id') id: string) {
return this.service.findOne(+id);
async findOne(@Param('id') id: string, @Request() req: { user: RequestUser }) {
return this.getAuthorizedSchedule(+id, req);
}
@Post()
@@ -116,6 +144,7 @@ export class SchedulesController {
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.assertClassAccess(req as { user: RequestUser }, dto.classId);
try {
const result = await this.service.create(dto);
await this.logService.log({
@@ -152,7 +181,9 @@ export class SchedulesController {
content: `教室${dto.classroomId}${dto.weekDay} ${dto.startTime}-${dto.endTime} 与已有排课冲突`,
});
}
} catch {}
} catch {
// Best-effort conflict notification must not hide the original conflict.
}
}
throw error;
}
@@ -166,7 +197,10 @@ export class SchedulesController {
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const existing = await this.service.findOne(+id);
const existing = await this.getAuthorizedSchedule(+id, req as { user: RequestUser });
if (dto.classId !== undefined && dto.classId !== existing.classId) {
await this.assertClassAccess(req as { user: RequestUser }, dto.classId);
}
try {
const result = await this.service.update(+id, dto);
await this.logService.log({
@@ -203,7 +237,9 @@ export class SchedulesController {
content: `教室${existing.classroomId}${existing.weekDay} ${existing.startTime}-${existing.endTime} (更新) 与已有排课冲突`,
});
}
} catch {}
} catch {
// Best-effort conflict notification must not hide the original conflict.
}
}
throw error;
}
@@ -216,6 +252,7 @@ export class SchedulesController {
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.getAuthorizedSchedule(+id, req as { user: RequestUser });
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,

View File

@@ -1,6 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
import {
ClassSchedule,
Class,
Classroom,
ClassroomRental,
ClassTeacher,
AttendanceSession,
} from '../entities';
import { SchedulesService } from './schedules.service';
import { SchedulesController } from './schedules.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@@ -8,7 +15,14 @@ import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [
TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental, ClassTeacher]),
TypeOrmModule.forFeature([
ClassSchedule,
Class,
Classroom,
ClassroomRental,
ClassTeacher,
AttendanceSession,
]),
OperationLogsModule,
NotificationsModule,
],

View File

@@ -39,3 +39,67 @@ describe('SchedulesService — teacher class scope', () => {
expect(qb.getMany).not.toHaveBeenCalled();
});
});
describe('SchedulesService — shared classroom occupancy visibility', () => {
it('shows other classes as masked busy blocks while preserving assigned-class details', async () => {
const qb = createQb();
qb.getMany.mockResolvedValue([
{
id: 1,
classId: 3,
classroomId: 10,
weekDay: 1,
startTime: '09:00',
endTime: '10:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
subject: '数学',
teacherId: 8,
scheduleType: 'INTERNAL',
status: 'active',
notes: '本班备注',
},
{
id: 2,
classId: 99,
classroomId: 10,
weekDay: 1,
startTime: '10:00',
endTime: '11:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
subject: '其他班隐私科目',
teacherId: 9,
scheduleType: 'INTERNAL',
status: 'active',
notes: '其他班备注',
},
]);
const service = new SchedulesService(
{ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never,
{} as never,
{} as never,
{} as never,
);
const result = await service.getWeeklyView({}, [3]);
const blocks = result[10][1];
expect(blocks[0]).toEqual(expect.objectContaining({ subject: '数学', canViewDetails: true }));
expect(blocks[1]).toEqual(
expect.objectContaining({
subject: '已占用',
classId: null,
teacherId: null,
notes: null,
canViewDetails: false,
}),
);
expect(JSON.stringify(blocks[1])).not.toContain('其他班隐私科目');
expect(JSON.stringify(blocks[1])).not.toContain('其他班备注');
expect(qb.andWhere).not.toHaveBeenCalledWith(
'cs.classId IN (:...accessibleClassIds)',
expect.anything(),
);
});
});

View File

@@ -7,6 +7,8 @@ import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Class } from '../entities/class.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { Classroom } from '../entities/classroom.entity';
/** Build a mock query-builder where each chain method returns `this`. */
function mockQueryBuilder<T>(results: T[] = []) {
@@ -20,6 +22,45 @@ function mockQueryBuilder<T>(results: T[] = []) {
return qb;
}
describe('SchedulesService — getLookups', () => {
it('includes active classrooms that have never been scheduled', async () => {
const classroom = { id: 7, name: '新教室', building: 'A座' } as Classroom;
const classroomRepo = { find: jest.fn().mockResolvedValue([classroom]) };
const scheduleQb = {
select: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
distinct: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
getRawMany: jest.fn().mockResolvedValue([]),
};
const module = await Test.createTestingModule({
providers: [
SchedulesService,
{
provide: getRepositoryToken(ClassSchedule),
useValue: { createQueryBuilder: jest.fn().mockReturnValue(scheduleQb) },
},
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo },
{ provide: getRepositoryToken(ClassroomRental), useValue: {} },
{ provide: getRepositoryToken(ClassTeacher), useValue: {} },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
],
}).compile();
const service = module.get(SchedulesService);
await expect(service.getLookups([1])).resolves.toMatchObject({ classrooms: [classroom] });
expect(classroomRepo.find).toHaveBeenCalledWith({
where: expect.any(Object),
select: ['id', 'name', 'building', 'floor', 'roomType'],
order: { building: 'ASC', name: 'ASC' },
});
});
});
describe('SchedulesService — checkConflict', () => {
let service: SchedulesService;
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'createQueryBuilder'>>;
@@ -34,6 +75,7 @@ describe('SchedulesService — checkConflict', () => {
providers: [
SchedulesService,
{ provide: getRepositoryToken(ClassSchedule), useValue: mockRepo },
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{
provide: getRepositoryToken(ClassroomRental),
@@ -43,6 +85,10 @@ describe('SchedulesService — checkConflict', () => {
provide: getRepositoryToken(ClassTeacher),
useValue: { find: jest.fn().mockResolvedValue([]) },
},
{
provide: getRepositoryToken(AttendanceSession),
useValue: { count: jest.fn().mockResolvedValue(0) },
},
],
}).compile();
@@ -141,13 +187,13 @@ describe('SchedulesService — checkConflict', () => {
describe('SchedulesService — getClassroomOccupancy', () => {
let service: SchedulesService;
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'createQueryBuilder'>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SchedulesService,
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
{
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
@@ -156,6 +202,10 @@ describe('SchedulesService — getClassroomOccupancy', () => {
provide: getRepositoryToken(ClassTeacher),
useValue: { find: jest.fn().mockResolvedValue([]) },
},
{
provide: getRepositoryToken(AttendanceSession),
useValue: { count: jest.fn().mockResolvedValue(0) },
},
],
}).compile();
@@ -191,3 +241,69 @@ describe('SchedulesService — getClassroomOccupancy', () => {
expect(qb.andWhere).toHaveBeenCalledWith('cs.endDate >= :date', { date: '2026-03-15' });
});
});
describe('SchedulesService — remove', () => {
let service: SchedulesService;
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'findOne' | 'remove'>>;
let attendanceSessionRepo: jest.Mocked<Pick<Repository<AttendanceSession>, 'count'>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SchedulesService,
{
provide: getRepositoryToken(ClassSchedule),
useValue: { findOne: jest.fn(), remove: jest.fn() },
},
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
{
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
},
{
provide: getRepositoryToken(ClassTeacher),
useValue: { find: jest.fn().mockResolvedValue([]) },
},
{
provide: getRepositoryToken(AttendanceSession),
useValue: { count: jest.fn() },
},
],
}).compile();
service = module.get<SchedulesService>(SchedulesService);
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
attendanceSessionRepo = module.get(getRepositoryToken(AttendanceSession));
});
it('deletes a schedule with no attendance sessions', async () => {
const schedule = { id: 1, subject: '数学' } as ClassSchedule;
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
(scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule);
(attendanceSessionRepo.count as jest.Mock).mockResolvedValue(0);
const result = await service.remove(1);
expect(result).toEqual({ success: true });
expect(scheduleRepo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
expect(scheduleRepo.remove).toHaveBeenCalledWith(schedule);
});
it('rejects deletion when attendance sessions exist', async () => {
const schedule = { id: 2, subject: '英语' } as ClassSchedule;
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
(scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule);
(attendanceSessionRepo.count as jest.Mock).mockResolvedValue(3);
await expect(service.remove(2)).rejects.toThrow(ConflictException);
expect(scheduleRepo.remove).not.toHaveBeenCalled();
});
it('throws NotFoundException for non-existent schedule', async () => {
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(null);
await expect(service.remove(999)).rejects.toThrow('排课记录不存在');
expect(scheduleRepo.remove).not.toHaveBeenCalled();
});
});

View File

@@ -2,12 +2,19 @@ import {
Injectable,
NotFoundException,
ConflictException,
ForbiddenException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
import { In, Not, Repository } from 'typeorm';
import {
ClassSchedule,
Class,
Classroom,
ClassroomRental,
ClassTeacher,
AttendanceSession,
} from '../entities';
import {
CreateScheduleDto,
UpdateScheduleDto,
@@ -21,10 +28,13 @@ export class SchedulesService {
@InjectRepository(ClassSchedule)
private readonly scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
@InjectRepository(Classroom) private readonly classroomRepo: Repository<Classroom>,
@InjectRepository(ClassroomRental)
private readonly rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher)
private readonly classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession)
private readonly attendanceSessionRepo: Repository<AttendanceSession>,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
@@ -33,6 +43,31 @@ export class SchedulesService {
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
if (canManageAll) return;
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
if (!assignment) throw new ForbiddenException('只能管理自己被分配班级的排课');
}
maskScheduleOccupancy(schedule: ClassSchedule) {
return {
id: null,
classId: null,
classroomId: schedule.classroomId,
weekDay: schedule.weekDay,
startTime: schedule.startTime,
endTime: schedule.endTime,
startDate: schedule.startDate,
endDate: schedule.endDate,
subject: '已占用',
teacherId: null,
scheduleType: schedule.scheduleType,
status: schedule.status,
notes: null,
canViewDetails: false,
};
}
async getLookups(accessibleClassIds?: number[]) {
const classes = accessibleClassIds
? accessibleClassIds.length > 0
@@ -47,24 +82,15 @@ export class SchedulesService {
order: { name: 'ASC' },
});
const classroomRows = await this.scheduleRepo
.createQueryBuilder('schedule')
.select('classroom.id', 'classroomId')
.addSelect('classroom.name', 'classroomName')
.addSelect('classroom.building', 'classroomBuilding')
.innerJoin('schedule.classroom', 'classroom')
.distinct(true)
.orderBy('classroom.building', 'ASC')
.addOrderBy('classroom.name', 'ASC')
.getRawMany();
const classrooms = await this.classroomRepo.find({
where: { status: Not('archived') },
select: ['id', 'name', 'building', 'floor', 'roomType'],
order: { building: 'ASC', name: 'ASC' },
});
return {
classes,
classrooms: classroomRows.map((row) => ({
id: Number(row.classroomId),
name: String(row.classroomName ?? ''),
building: String(row.classroomBuilding ?? ''),
})),
classrooms,
};
}
@@ -181,6 +207,16 @@ export class SchedulesService {
async remove(id: number) {
const schedule = await this.scheduleRepo.findOne({ where: { id } });
if (!schedule) throw new NotFoundException('排课记录不存在');
const sessionCount = await this.attendanceSessionRepo.count({
where: { scheduleId: id },
});
if (sessionCount > 0) {
throw new ConflictException(
`无法删除已产生 ${sessionCount} 个考勤场次的排课。请先取消或停用排课以保护历史考勤数据。`,
);
}
await this.scheduleRepo.remove(schedule);
return { success: true };
}
@@ -236,10 +272,6 @@ export class SchedulesService {
if (query.classroomId) {
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
}
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return {};
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.startDate) {
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
}
@@ -253,12 +285,25 @@ export class SchedulesService {
.addOrderBy('cs.startTime', 'ASC')
.getMany();
const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null;
const visibleSchedules = schedules.map((schedule) => {
const canViewDetails =
allowedClassIds === null ||
(schedule.classId !== null && allowedClassIds.has(schedule.classId));
if (canViewDetails) return { ...schedule, canViewDetails: true };
// Other classes remain visible only as a room/time occupancy block.
// Do not expose class, subject, teacher, notes, or internal record IDs.
return this.maskScheduleOccupancy(schedule);
});
// Group by classroomId → weekDay
const matrix: Record<number, Record<number, typeof schedules>> = {};
for (const s of schedules) {
if (!matrix[s.classroomId]) matrix[s.classroomId] = {};
if (!matrix[s.classroomId][s.weekDay]) matrix[s.classroomId][s.weekDay] = [];
matrix[s.classroomId][s.weekDay].push(s);
const matrix: Record<number, Record<number, typeof visibleSchedules>> = {};
for (const schedule of visibleSchedules) {
if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {};
if (!matrix[schedule.classroomId][schedule.weekDay])
matrix[schedule.classroomId][schedule.weekDay] = [];
matrix[schedule.classroomId][schedule.weekDay].push(schedule);
}
return matrix;

View File

@@ -52,6 +52,12 @@ export class StudentsController {
);
}
@Get('basic-lookups')
@RequirePermission('student:basic-view', 'student:view')
getBasicLookups() {
return this.service.getBasicLookups();
}
@Get()
@RequirePermission('student:view')
async findAll(

View File

@@ -27,6 +27,14 @@ export class StudentsService {
return [...new Set(assignments.map((assignment) => assignment.classId))];
}
async getBasicLookups() {
return this.repo.find({
select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'],
where: { status: 'active' },
order: { name: 'ASC' },
});
}
async findAll(
query?: {
name?: string;

View File

@@ -28,7 +28,7 @@ describe('ScheduleSyncService — absence threshold', () => {
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
};
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_16:00-17:00' }]),
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '冲刺班_16:00-17:00' }]),
upsertShift: jest.fn().mockResolvedValue(456),
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
@@ -50,7 +50,7 @@ describe('ScheduleSyncService — absence threshold', () => {
expect(dingTalkService.upsertShift).toHaveBeenCalledWith(expect.objectContaining({
id: 456,
name: '排课_16:00-17:00',
name: '冲刺班_16:00-17:00',
setting: expect.objectContaining({ absenteeism_late_minutes: 60 }),
}));
});
@@ -118,3 +118,347 @@ describe('ScheduleSyncService — attendance machine only', () => {
expect(dingTalkService.createAttendanceGroup).not.toHaveBeenCalled();
});
});
describe('ScheduleSyncService — partial batch failure', () => {
it('reports failedBatchCount > 0 when a scheduleUsers batch fails, not full success', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 1,
classId: 10,
classroomId: 1,
weekDay: 1,
startTime: '09:00',
endTime: '11:00',
startDate: '2026-07-06',
endDate: '2026-07-06',
status: 'active',
} as ClassSchedule,
{
id: 2,
classId: 20,
classroomId: 2,
weekDay: 2,
startTime: '14:00',
endTime: '16:00',
startDate: '2026-07-07',
endDate: '2026-07-07',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([
{ classId: 10, studentId: 20, status: 'active' },
{ classId: 20, studentId: 30, status: 'active' },
]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([
{ studentId: 20, dingUserId: 'student-1' },
{ studentId: 30, dingUserId: 'student-2' },
]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([
{ id: 10, name: '冲刺班' },
{ id: 20, name: '强化班' },
]),
};
const scheduleUsers = jest
.fn()
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('钉钉排班失败: rate limited (code=33018)'));
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([
{ id: 900, name: '排课_09:00-11:00' },
{ id: 901, name: '排课_14:00-16:00' },
]),
upsertShift: jest.fn().mockResolvedValue(900).mockResolvedValueOnce(900).mockResolvedValueOnce(901),
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 777, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup: jest.fn().mockResolvedValue(888),
scheduleUsers,
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
const result = await service.syncAll('2026-07-06', 2);
expect(scheduleUsers).toHaveBeenCalledTimes(2);
expect(result.failedBatchCount).toBeGreaterThan(0);
expect(result.errors).toBeDefined();
expect(result.errors!.length).toBeGreaterThan(0);
// syncedItems should only count the successful batch
expect(result.syncedItems).toBeGreaterThan(0);
});
});
describe('ScheduleSyncService — attendance group failure', () => {
it('counts createAttendanceGroup failure as real failure, not skippedNoMapping', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 1,
classId: 10,
classroomId: 1,
weekDay: 1,
startTime: '09:00',
endTime: '11:00',
startDate: '2026-07-06',
endDate: '2026-07-06',
status: 'active',
} as ClassSchedule,
{
id: 2,
classId: 20,
classroomId: 2,
weekDay: 2,
startTime: '14:00',
endTime: '16:00',
startDate: '2026-07-07',
endDate: '2026-07-07',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([
{ classId: 10, studentId: 20, status: 'active' },
{ classId: 20, studentId: 30, status: 'active' },
]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([
{ studentId: 20, dingUserId: 'student-1' },
{ studentId: 30, dingUserId: 'student-2' },
]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([
{ id: 10, name: '冲刺班' },
{ id: 20, name: '强化班' },
]),
};
const createAttendanceGroup = jest
.fn()
.mockRejectedValue(new Error('钉钉考勤组创建失败: insuffient permission (code=403)'));
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([
{ id: 900, name: '排课_09:00-11:00' },
{ id: 901, name: '排课_14:00-16:00' },
]),
upsertShift: jest.fn().mockResolvedValue(900),
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 888, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup,
scheduleUsers: jest.fn().mockResolvedValue(undefined),
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
const result = await service.syncAll('2026-07-06', 2);
// group failure must NOT be counted as skippedNoMapping
expect(result.skippedNoMapping).toBe(0);
// group failure must increment failure counters
expect(result.failedBatchCount).toBeGreaterThan(0);
expect(result.failedItems).toBeGreaterThan(0);
// error message must contain the group failure detail
expect(result.errors).toBeDefined();
expect(result.errors!.some((e) => e.includes('考勤组'))).toBe(true);
expect(result.errors!.some((e) => e.includes('强化班'))).toBe(true);
// the successful class should still sync
expect(result.syncedItems).toBeGreaterThan(0);
expect(result.groupCount).toBe(1);
});
});
describe('ScheduleSyncService — dedup', () => {
it('does not write duplicate schedule items for same user/date/shift', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 1,
classId: 10,
classroomId: 1,
weekDay: 3,
startTime: '10:00',
endTime: '12:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
status: 'active',
} as ClassSchedule,
{
id: 2,
classId: 10,
classroomId: 1,
weekDay: 3,
startTime: '10:00',
endTime: '12:00',
startDate: '2026-07-08',
endDate: '2026-07-08',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
};
const scheduleUsers = jest.fn().mockResolvedValue(undefined);
const dingTalkService = {
queryShifts: jest
.fn()
.mockResolvedValue([{ id: 456, name: '排课_10:00-12:00' }]),
upsertShift: jest.fn().mockResolvedValue(456),
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup: jest.fn(),
scheduleUsers,
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
await service.syncAll('2026-07-01', 31);
const batchItems = scheduleUsers.mock.calls[0][1] as Array<{ userid: string; work_date: number; shift_id: number }>;
// 2026-07-08 is a Wednesday (weekDay 3), so both schedules hit that date.
// The dedup should collapse the two identical {userid, work_date, shift_id} items into one.
const key = (item: { userid: string; work_date: number; shift_id: number }) =>
`${item.userid}-${item.work_date}-${item.shift_id}`;
const seen = new Set<string>();
for (const item of batchItems) {
const k = key(item);
expect(seen.has(k)).toBe(false);
seen.add(k);
}
// At least one item exists for 07-08 (proving overlap was handled)
// work_date is epoch ms at 00:00:00+08:00; convert back to date string
const fmtDate = (epochMs: number) => {
const d = new Date(epochMs);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000)
.toISOString().slice(0, 10);
};
const july8Items = batchItems.filter((i) => fmtDate(i.work_date) === '2026-07-08');
expect(july8Items.length).toBe(1);
});
});
describe('ScheduleSyncService — all shifts fail', () => {
it('counts failedBatchCount and failedItems when every shift creation fails, never skippedNoMapping', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 1,
classId: 10,
classroomId: 1,
weekDay: 1,
startTime: '09:00',
endTime: '11:00',
startDate: '2026-07-06',
endDate: '2026-07-06',
status: 'active',
} as ClassSchedule,
{
id: 2,
classId: 10,
classroomId: 1,
weekDay: 2,
startTime: '14:00',
endTime: '16:00',
startDate: '2026-07-07',
endDate: '2026-07-07',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([
{ classId: 10, studentId: 20, status: 'active' },
]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
};
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([]),
upsertShift: jest.fn().mockRejectedValue(new Error('钉钉班次创建失败: permission denied')),
queryAttendanceGroups: jest.fn().mockResolvedValue([]),
updateAttendanceGroup: jest.fn(),
createAttendanceGroup: jest.fn(),
scheduleUsers: jest.fn(),
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
const result = await service.syncAll('2026-07-06', 2);
// All shifts failed → no shifts created
expect(result.shiftCount).toBe(0);
// No attendance groups created (no usable shifts)
expect(result.groupCount).toBe(0);
// Nothing synced
expect(result.syncedItems).toBe(0);
// Must NOT count as skippedNoMapping
expect(result.skippedNoMapping).toBe(0);
// Failure counters must reflect the failed shifts
expect(result.failedBatchCount).toBeGreaterThan(0);
expect(result.failedItems).toBeGreaterThan(0);
// Errors must contain shift failure messages
expect(result.errors).toBeDefined();
expect(result.errors!.length).toBeGreaterThan(0);
expect(result.errors!.some((e) => e.includes('班次'))).toBe(true);
// No scheduleUsers calls (no group created)
expect(dingTalkService.scheduleUsers).not.toHaveBeenCalled();
});
});

View File

@@ -21,6 +21,12 @@ export interface ScheduleSyncResult {
syncedItems: number;
/** 因无学生或无钉钉映射而跳过的排课数 */
skippedNoMapping: number;
/** 写入失败的排班批次数 */
failedBatchCount: number;
/** 写入失败的排班条数 */
failedItems: number;
/** 失败批次错误详情 */
errors: string[];
/** 按班级分组的详情 */
groups: Array<{
className: string;
@@ -39,6 +45,13 @@ export interface ScheduleSyncResult {
* 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次)
* 5. 将排课展开为每个学生的每日排班,批量写入钉钉
*
* ## 残余风险:同步窗口内已不存在的旧排班无法清理
* 钉钉开放平台未暴露排班删除接口(仅提供 `schedule/listbyusers` 查询和
* `group/schedule/async` 写入)。`queryScheduleByUsers` 受限于 7 天窗口
* 和每次 50 个用户,且无配套删除能力,无法在同步前清理旧排班。
* 当前产品流程为"排课后手动同步钉钉",依赖运营人员知晓同步时机;
* 若后续需要自动清理,需等钉钉开放排班删除 API 或改用考勤组覆盖策略。
*
* ## API 调用优化
* - 班次列表、考勤组列表各只查询一次,在内存中按名称匹配,避免每次 findOrCreate 都发一次查询。
* - 排班写入按考勤组分批(钉钉单次最多 200 条)。
@@ -77,7 +90,9 @@ export class ScheduleSyncService {
const empty: ScheduleSyncResult = {
scheduleCount: 0, shiftCount: 0, groupCount: 0,
syncedItems: 0, skippedNoMapping: 0, groups: [],
syncedItems: 0, skippedNoMapping: 0,
failedBatchCount: 0, failedItems: 0, errors: [],
groups: [],
};
// ── Step 1: 查询活跃排课(必须关联到班级才能取学生) ──
@@ -93,23 +108,37 @@ export class ScheduleSyncService {
// ── Step 2: 班级 → 学生钉钉ID 映射 ──
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
const classDingUsers = await this.buildClassDingUserMap(classIds);
const classNameMap = await this.loadClassNames(classIds);
// ── Step 3: 班次(按时间段去重,班次列表只查一次) ──
const shiftKey = (start: string, end: string) => `${start}-${end}`;
const uniqueShifts = new Map<string, { startTime: string; endTime: string }>();
for (const s of schedules) {
const key = shiftKey(s.startTime, s.endTime);
const shiftKey = (classId: number, start: string, end: string) =>
`${classId}|${start}-${end}`;
const uniqueShifts = new Map<
string,
{ className: string; startTime: string; endTime: string }
>();
const shiftScheduleCount = new Map<string, number>();
for (const schedule of schedules) {
const classId = schedule.classId as number;
const key = shiftKey(classId, schedule.startTime, schedule.endTime);
if (!uniqueShifts.has(key)) {
uniqueShifts.set(key, { startTime: s.startTime, endTime: s.endTime });
uniqueShifts.set(key, {
className: classNameMap.get(classId) || `班级${classId}`,
startTime: schedule.startTime,
endTime: schedule.endTime,
});
}
shiftScheduleCount.set(key, (shiftScheduleCount.get(key) || 0) + 1);
}
const existingShifts = await this.dingTalkService.queryShifts(opUserId);
const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id]));
const timeToShiftId = new Map<string, number>();
const errors: string[] = [];
let failedBatchCount = 0;
let failedItems = 0;
let shiftCount = 0;
for (const [key, { startTime, endTime }] of uniqueShifts) {
const shiftName = `排课_${startTime}-${endTime}`;
for (const [key, { className, startTime, endTime }] of uniqueShifts) {
const shiftName = `${className}_${startTime}-${endTime}`;
try {
let shiftId = shiftByName.get(shiftName);
const shiftParams = {
@@ -133,7 +162,11 @@ export class ScheduleSyncService {
timeToShiftId.set(key, shiftId);
shiftCount++;
} catch (e) {
this.logger.error(`创建班次 ${shiftName} 失败: ${(e as Error).message}`);
const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`;
this.logger.error(msg);
errors.push(msg);
failedBatchCount++;
failedItems += shiftScheduleCount.get(key) || 0;
}
}
@@ -142,7 +175,6 @@ export class ScheduleSyncService {
const groupByName = new Map(existingGroups.map((g) => [g.group_name, g.group_id]));
// ── Step 5: 按班级同步 ──
const classNameMap = await this.loadClassNames(classIds);
const schedulesByClass = new Map<number, ClassSchedule[]>();
for (const s of schedules) {
const cid = s.classId as number;
@@ -154,7 +186,6 @@ export class ScheduleSyncService {
let skippedNoMapping = 0;
let groupCount = 0;
const groupDetails: ScheduleSyncResult['groups'] = [];
for (const [classId, classSchedules] of schedulesByClass) {
const className = classNameMap.get(classId) || `班级${classId}`;
const dingUserIds = classDingUsers.get(classId) ?? [];
@@ -168,11 +199,21 @@ export class ScheduleSyncService {
// 该班级用到的班次
const classShiftIds = new Set<number>();
for (const s of classSchedules) {
const sid = timeToShiftId.get(shiftKey(s.startTime, s.endTime));
const sid = timeToShiftId.get(shiftKey(classId, s.startTime, s.endTime));
if (sid) classShiftIds.add(sid);
}
if (classShiftIds.size === 0) {
this.logger.warn(`班级 ${className} 无可用班次,跳过`);
this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure`);
continue;
}
// 先展开排班以计算受影响条数
const items = this.expandSchedules(
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
);
if (items.length === 0) {
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
skippedNoMapping += classSchedules.length;
continue;
}
@@ -205,16 +246,14 @@ export class ScheduleSyncService {
}
groupCount++;
} catch (e) {
this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`);
skippedNoMapping += classSchedules.length;
const msg = `考勤组 ${groupName} 创建/更新失败: ${(e as Error).message}`;
this.logger.error(msg);
errors.push(msg);
failedBatchCount++;
failedItems += items.length;
continue;
}
// 展开为每个学生的每日排班
const items = this.expandSchedules(
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
);
// 批量写入单次≤200
let classItems = 0;
for (let i = 0; i < items.length; i += 200) {
@@ -224,7 +263,11 @@ export class ScheduleSyncService {
syncedItems += batch.length;
classItems += batch.length;
} catch (e) {
this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`);
const msg = `排班写入失败 (groupId=${attendanceGroupId}, batch=${Math.floor(i / 200) + 1}): ${(e as Error).message}`;
this.logger.error(msg);
failedBatchCount++;
failedItems += batch.length;
errors.push(msg);
}
}
@@ -233,7 +276,8 @@ export class ScheduleSyncService {
this.logger.log(
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`,
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` +
(failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''),
);
return {
@@ -242,6 +286,9 @@ export class ScheduleSyncService {
groupCount,
syncedItems,
skippedNoMapping,
failedBatchCount,
failedItems,
errors,
groups: groupDetails,
};
}
@@ -297,6 +344,7 @@ export class ScheduleSyncService {
syncFrom: string,
syncTo: string,
): DingTalkScheduleItem[] {
const seen = new Set<string>();
const items: DingTalkScheduleItem[] = [];
const fromDate = new Date(syncFrom);
const toDate = new Date(syncTo);
@@ -309,7 +357,7 @@ export class ScheduleSyncService {
}
for (const s of schedules) {
const shiftId = timeToShiftId.get(`${s.startTime}-${s.endTime}`);
const shiftId = timeToShiftId.get(`${s.classId}|${s.startTime}-${s.endTime}`);
if (!shiftId) continue;
const scheduleStart = s.startDate > syncFrom ? s.startDate : syncFrom;
@@ -321,6 +369,9 @@ export class ScheduleSyncService {
const workDate = new Date(dateStr + 'T00:00:00+08:00').getTime();
for (const userid of dingUserIds) {
const dedupKey = `${userid}|${workDate}|${shiftId}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
}
}
@@ -329,6 +380,7 @@ export class ScheduleSyncService {
return items;
}
private minutesBetween(startTime: string, endTime: string): number {
const [startHour, startMinute] = startTime.split(':').map(Number);
const [endHour, endMinute] = endTime.split(':').map(Number);

View File

@@ -50,6 +50,21 @@ export class SyncController {
return { success: true, data: tree };
}
@Get('dingtalk/attendance-groups')
@RequirePermission('sync:read')
async getDingTalkAttendanceGroups() {
return { success: true, data: await this.syncService.getDingTalkAttendanceGroups() };
}
@Post('dingtalk/attendance-groups/delete-all')
@RequirePermission('sync:trigger')
async deleteAllDingTalkAttendanceGroups() {
return {
success: true,
data: await this.syncService.deleteAllDingTalkAttendanceGroups(),
};
}
@Get('logs')
@RequirePermission('sync:read')
async getLogs(

View File

@@ -98,6 +98,29 @@ export class SyncService {
return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
}
async getDingTalkAttendanceGroups() {
return this.dingTalkService.queryAttendanceGroups();
}
async deleteAllDingTalkAttendanceGroups() {
const groups = await this.dingTalkService.queryAttendanceGroups();
const deleted: Array<{ groupId: number; groupName: string }> = [];
const failed: Array<{ groupId: number; groupName: string; error: string }> = [];
for (const group of groups) {
try {
await this.dingTalkService.deleteAttendanceGroup(group.group_id);
deleted.push({ groupId: group.group_id, groupName: group.group_name });
} catch (error: unknown) {
failed.push({
groupId: group.group_id,
groupName: group.group_name,
error: error instanceof Error ? error.message : String(error),
});
}
}
return { total: groups.length, deleted, failed };
}
// ── 排班同步 ──
/** 将本地排课同步到钉钉考勤排班 */