feat: add CASL authorization and AI configuration

This commit is contained in:
2026-07-11 14:25:34 +08:00
parent 8f0991a51f
commit 1e1c476bc3
59 changed files with 7733 additions and 120 deletions

View File

@@ -15,6 +15,9 @@ describe('AttendanceController — DingTalk import scope', () => {
const logService = {
log: jest.fn(),
};
const authzService = {
can: jest.fn().mockReturnValue(false),
};
let controller: AttendanceController;
@@ -24,6 +27,7 @@ describe('AttendanceController — DingTalk import scope', () => {
attendanceService as unknown as AttendanceService,
importService as unknown as AttendanceImportService,
logService as unknown as OperationLogsService,
authzService as never,
);
importService.importFromDingTalk.mockResolvedValue({
success: true,
@@ -115,6 +119,7 @@ describe('AttendanceController — DingTalk import scope', () => {
it('allows class managers to choose any importable class and supply explicit DingTalk users', async () => {
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 1, className: '一班' }]);
authzService.can.mockReturnValue(true);
await expect(
controller.getDingTalkImportClasses({
user: {

View File

@@ -35,6 +35,8 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import * as ExcelJS from 'exceljs';
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
import type { AuthenticatedUser } from '../authorization';
/** SSE event shape for @Sse() decorator */
interface SseEvent {
@@ -47,8 +49,9 @@ interface SseEvent {
interface RequestUser {
id: number;
username: string;
permissions?: string[];
isSuperAdmin?: boolean;
permissions: string[];
isSuperAdmin: boolean;
roles: string[];
}
@UseGuards(JwtAuthGuard)
@@ -58,6 +61,7 @@ export class AttendanceController {
private readonly service: AttendanceService,
private readonly importService: AttendanceImportService,
private readonly logService: OperationLogsService,
private readonly authz: AuthorizationService,
) {}
private getTodayDateOnly(): string {
@@ -68,16 +72,20 @@ export class AttendanceController {
return `${year}-${month}-${day}`;
}
private canManageAllAttendance(user: RequestUser): boolean {
return user.isSuperAdmin === true || user.permissions?.includes('class:edit') === true;
private canManageAllAttendance(req: { user: RequestUser }): boolean {
return (
this.authz.can(req, CaslAction.Manage, SubjectName.Attendance) ||
// Legacy: class:edit grants broad attendance access for teacher scoping
this.authz.can(req, CaslAction.Update, SubjectName.Class)
);
}
private getAccessibleClassIds(user: RequestUser) {
return this.service.getAccessibleClassIds(user.id, this.canManageAllAttendance(user));
private getAccessibleClassIds(req: { user: RequestUser }) {
return this.service.getAccessibleClassIds(req.user.id, this.canManageAllAttendance(req));
}
private assertClassAccess(user: RequestUser, classId: number) {
return this.service.assertClassAccess(user.id, classId, this.canManageAllAttendance(user));
private assertClassAccess(req: { user: RequestUser }, classId: number) {
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
}
// ── Batch create attendance records ──
@@ -124,8 +132,8 @@ export class AttendanceController {
@Res() res: Response,
@Request() req: { user: RequestUser },
) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
const classIds = await this.getAccessibleClassIds(req.user);
if (query.classId) await this.assertClassAccess(req, query.classId);
const classIds = await this.getAccessibleClassIds(req);
const records = await this.service.findAllForExport(query, classIds);
const workbook = new ExcelJS.Workbook();
@@ -176,8 +184,8 @@ export class AttendanceController {
@Get('attendance-records')
@RequirePermission('attendance:view')
async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
return this.service.findAll(query, await this.getAccessibleClassIds(req.user));
if (query.classId) await this.assertClassAccess(req, query.classId);
return this.service.findAll(query, await this.getAccessibleClassIds(req));
}
// ── Update a single attendance record ──
@@ -228,7 +236,7 @@ export class AttendanceController {
@Get('attendance-records/classes')
@RequirePermission('attendance:view')
async getClasses(@Request() req: { user: RequestUser }) {
return this.service.getClasses(await this.getAccessibleClassIds(req.user));
return this.service.getClasses(await this.getAccessibleClassIds(req));
}
// ── Attendance summary ──
@@ -238,8 +246,8 @@ export class AttendanceController {
@Query() query: AttendanceSummaryQueryDto,
@Request() req: { user: RequestUser },
) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
return this.service.getSummary(query, await this.getAccessibleClassIds(req.user));
if (query.classId) await this.assertClassAccess(req, query.classId);
return this.service.getSummary(query, await this.getAccessibleClassIds(req));
}
// ── Attendance calendar ──
@@ -249,7 +257,7 @@ export class AttendanceController {
@Query() query: AttendanceCalendarQueryDto,
@Request() req: { user: RequestUser },
) {
await this.assertClassAccess(req.user, query.classId);
await this.assertClassAccess(req, query.classId);
return this.service.getCalendar(query);
}
@@ -257,8 +265,8 @@ export class AttendanceController {
@Get('ding-attendance-raw')
@RequirePermission('attendance:view')
async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req.user));
if (query.classId) await this.assertClassAccess(req, query.classId);
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req));
}
// ── Match a dingtalk record to a student ──
@@ -293,11 +301,8 @@ export class AttendanceController {
@Res() res: Response,
@Request() req: any,
) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
const reportData = await this.service.getReport(
query,
await this.getAccessibleClassIds(req.user),
);
if (query.classId) await this.assertClassAccess(req, query.classId);
const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req));
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('考勤统计报表');
@@ -363,7 +368,7 @@ export class AttendanceController {
return this.service.getAlerts(
days ? +days : 14,
threshold ? +threshold : 3,
await this.getAccessibleClassIds(req.user),
await this.getAccessibleClassIds(req),
);
}
@@ -380,7 +385,7 @@ export class AttendanceController {
@Get('attendance-records/import/dingtalk/classes')
@RequirePermission('attendance:create')
getDingTalkImportClasses(@Request() req: { user: RequestUser }) {
return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req.user));
return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req));
}
/**
@@ -392,7 +397,7 @@ export class AttendanceController {
@RequirePermission('attendance:create')
async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const canManageAll = this.canManageAllAttendance(req.user);
const canManageAll = this.canManageAllAttendance(req);
let userIds: string[];
if (dto.users) {