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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
492
apps/server/src/attendance/attendance.lesson-session.spec.ts
Normal file
492
apps/server/src/attendance/attendance.lesson-session.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (Monday–Sunday)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
380
apps/server/src/database/attendance-fk-restrict.spec.ts
Normal file
380
apps/server/src/database/attendance-fk-restrict.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('押金记录不存在');
|
||||
|
||||
@@ -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;
|
||||
|
||||
62
apps/server/src/entities/attendance-session.entity.ts
Normal file
62
apps/server/src/entities/attendance-session.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
53
apps/server/src/integration/dingtalk.group-delete.spec.ts
Normal file
53
apps/server/src/integration/dingtalk.group-delete.spec.ts
Normal 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' }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 考勤排班 — 排班分配
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
54
apps/server/src/rbac/rbac.teacher-workspace.spec.ts
Normal file
54
apps/server/src/rbac/rbac.teacher-workspace.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
126
apps/server/src/schedules/schedules.controller.spec.ts
Normal file
126
apps/server/src/schedules/schedules.controller.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
// ── 排班同步 ──
|
||||
|
||||
/** 将本地排课同步到钉钉考勤排班 */
|
||||
|
||||
Reference in New Issue
Block a user