fix(security): 认证/越权/注入/上传/凭据全链路加固

由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- JWT 生产必填、密码 8-72 字节、防枚举;全局 ValidationPipe
- classes/dashboard/schedules/students/attendance/archive/exams 越权与 IDOR 修复
- LIKE 通配符转义(14 处);上传 10MB 上限 + MIME 白名单 + 附件 XSS
- 集成配置 appSecret AES 加密 + 回填脚本;审计 best-effort;IP 来源防伪造

Reviewed-by: OCR (open-codereview.ai)
This commit is contained in:
2026-08-09 21:29:22 +08:00
parent 3bcad138a1
commit 99ea931409
61 changed files with 1249 additions and 175 deletions

View File

@@ -14,6 +14,7 @@
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"generate:student-import": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/generate-student-import-xlsx.ts", "generate:student-import": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/generate-student-import-xlsx.ts",
"encrypt:integration-secrets": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/encrypt-integration-secrets.ts",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:fix": "npm run lint -- --fix", "lint:fix": "npm run lint -- --fix",
"typecheck": "tsc -p tsconfig.build.json --noEmit", "typecheck": "tsc -p tsconfig.build.json --noEmit",

View File

@@ -0,0 +1,67 @@
/// <reference types="node" />
import datasource from '../datasource';
import { encryptSecret, isEncryptedSecret } from '../src/integration/config/secret-crypto';
import { IntegrationConfigDetail } from '../src/integration/entities/integration-config.entity';
/** integration_config_detail.content JSON 中与本次回填相关的结构。 */
interface StoredConfigContent {
type?: unknown;
verify?: unknown;
config?: Record<string, unknown>;
}
async function main(): Promise<void> {
// 主动检查加密密钥:缺失时 getEncryptionKey() 会静默使用开发回退密钥,
// 绝不能用回退密钥加密生产数据,因此未配置时直接报错退出。
if (!process.env.AI_CONFIG_ENCRYPTION_KEY) {
throw new Error(
'AI_CONFIG_ENCRYPTION_KEY 未设置:为避免使用开发回退密钥加密数据,请先配置 AI_CONFIG_ENCRYPTION_KEY 再运行回填脚本',
);
}
await datasource.initialize();
console.log('已连接数据库,开始回填第三方集成配置 appSecret 加密...');
try {
const repo = datasource.getRepository(IntegrationConfigDetail);
const rows = await repo.find();
let processed = 0;
for (const row of rows) {
if (!row.content) continue;
let parsed: StoredConfigContent;
try {
parsed = JSON.parse(row.content) as StoredConfigContent;
} catch {
continue;
}
const config = parsed.config;
if (!config || typeof config !== 'object' || Array.isArray(config)) continue;
const appSecret = config.appSecret;
if (typeof appSecret !== 'string' || !appSecret || isEncryptedSecret(appSecret)) continue;
config.appSecret = encryptSecret(appSecret);
row.content = JSON.stringify(parsed);
await repo.save(row);
processed += 1;
}
console.log(`回填完成:共处理 ${processed} 行(加密 appSecret`);
} finally {
// 无论成功还是失败都关闭连接池,避免泄漏
try {
await datasource.destroy();
} catch {
// 销毁失败不影响主流程结果
}
}
}
main().catch((error) => {
console.error('回填失败:', error);
process.exitCode = 1;
});

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Delete, Delete,
@@ -108,6 +109,7 @@ export class AiChatController {
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
) { ) {
if (!file) throw new BadRequestException('缺少上传文件');
const attachment = await this.attachmentService.upload(req.user.id, file); const attachment = await this.attachmentService.upload(req.user.id, file);
return { success: true, data: this.attachmentService.serialize(attachment) }; return { success: true, data: this.attachmentService.serialize(attachment) };
} }

View File

@@ -7,6 +7,7 @@ import { RoomsService } from '../rooms/rooms.service';
import type { AiReviewSection } from './entities/ai-review.entity'; import type { AiReviewSection } from './entities/ai-review.entity';
import { MAX_CAPACITY, normalizePhone } from './ai-review.shared'; import { MAX_CAPACITY, normalizePhone } from './ai-review.shared';
import { resolveOrganizationId } from './ai-review.enrich'; import { resolveOrganizationId } from './ai-review.enrich';
import { addDaysToDateOnly } from '../common/china-time';
export async function importStudents( export async function importStudents(
section: AiReviewSection | undefined, section: AiReviewSection | undefined,
@@ -172,10 +173,5 @@ export function normalizeFloor(raw: unknown): number | null {
} }
export function nextDay(date: string): string { export function nextDay(date: string): string {
const parsed = new Date(`${date}T00:00:00+08:00`); return addDaysToDateOnly(date, 1);
parsed.setDate(parsed.getDate() + 1);
const year = parsed.getFullYear();
const month = String(parsed.getMonth() + 1).padStart(2, '0');
const day = String(parsed.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} }

View File

@@ -1,18 +1,5 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import { ArrayMaxSize, IsArray, IsIn, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID, Max, MaxLength, Min, registerDecorator } from 'class-validator';
ArrayMaxSize,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { REASONING_EFFORT_LEVELS } from '../../ai-config/dto/ai-config.dto'; import { REASONING_EFFORT_LEVELS } from '../../ai-config/dto/ai-config.dto';
export class CreateConversationDto { export class CreateConversationDto {
@@ -89,11 +76,39 @@ export class EditMessageDto {
reasoningEffort?: string | null; reasoningEffort?: string | null;
} }
const MAX_FORM_VALUES = 200;
/** 限制提交表单的字段数量,防止超大 body。 */
export function MaxFormValues(limit = MAX_FORM_VALUES) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'maxFormValues',
target: object.constructor,
propertyName,
constraints: [limit],
validator: {
validate(value: unknown) {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.keys(value).length <= limit
);
},
defaultMessage(args) {
return `values 字段数量不能超过 ${args?.constraints?.[0] ?? limit}`;
},
},
});
};
}
export class SubmitFormDto { export class SubmitFormDto {
@IsUUID() @IsUUID()
clientRequestId: string; clientRequestId: string;
@IsObject() @IsObject()
@MaxFormValues()
values: Record<string, unknown>; values: Record<string, unknown>;
@IsOptional() @IsOptional()

View File

@@ -12,8 +12,12 @@ import {
UploadedFile, UploadedFile,
Res, Res,
ParseIntPipe, ParseIntPipe,
BadRequestException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { normalizeMimeType, isInlineSafeMimeType } from '../common/mime';
import type { Request as ExpressRequest, Response } from 'express'; import type { Request as ExpressRequest, Response } from 'express';
import * as fs from 'fs'; import * as fs from 'fs';
import { ArchiveReportService } from './archive-report.service'; import { ArchiveReportService } from './archive-report.service';
@@ -30,11 +34,17 @@ import {
} from './dto/archive.dto'; } from './dto/archive.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { withAuditLog } from '../common/with-audit-log'; import { withAuditLog, logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator'; import { RequirePermission } from '../auth/decorators/permission.decorator';
import { StudentsService } from '../students/students.service';
interface AuthenticatedRequest extends ExpressRequest { interface AuthenticatedRequest extends ExpressRequest {
user?: { id: number; username?: string }; user?: {
id: number;
username?: string;
permissions?: string[];
isSuperAdmin?: boolean;
};
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -44,11 +54,42 @@ export class ArchiveController {
private readonly archiveService: ArchiveService, private readonly archiveService: ArchiveService,
private readonly logService: OperationLogsService, private readonly logService: OperationLogsService,
private readonly reportService: ArchiveReportService, private readonly reportService: ArchiveReportService,
private readonly studentsService: StudentsService,
) {} ) {}
/** 超管或拥有 class:edit等价于学生全范围时视为可管理所有档案。 */
private canManageAllArchive(req: AuthenticatedRequest): boolean {
const user = req.user;
if (!user) return false;
return user.isSuperAdmin === true || (user.permissions ?? []).includes('class:edit');
}
/** 校验当前用户能否访问指定学生的档案(防 IDOR。 */
private async assertStudentAccess(req: AuthenticatedRequest, studentId: number) {
const user = req.user;
if (!user) throw new UnauthorizedException();
await this.studentsService.assertStudentAccess(
user.id,
studentId,
this.canManageAllArchive(req),
);
}
/** 按子记录 id 校验归属:先解析其 studentId再做学生级范围校验。 */
private async assertRecordAccess(
req: AuthenticatedRequest,
kind: 'enrollment' | 'examScore' | 'learningRecord' | 'attachment',
id: number,
) {
const studentId = await this.archiveService.resolveRecordStudentId(kind, id);
if (studentId == null) throw new NotFoundException('记录不存在');
await this.assertStudentAccess(req, studentId);
}
@Get(':studentId') @Get(':studentId')
@RequirePermission('student:view') @RequirePermission('student:view')
async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) { async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) {
await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '查看档案', targetId: studentId, targetType: 'archive', module: '学生档案', action: '查看档案', targetId: studentId, targetType: 'archive',
}), () => this.archiveService.getProfile(studentId)); }), () => this.archiveService.getProfile(studentId));
@@ -61,6 +102,7 @@ export class ArchiveController {
@Body() dto: UpsertProfileDto, @Body() dto: UpsertProfileDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '更新档案信息', targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto), module: '学生档案', action: '更新档案信息', targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto),
}), () => this.archiveService.upsertProfile(studentId, dto)); }), () => this.archiveService.upsertProfile(studentId, dto));
@@ -73,6 +115,7 @@ export class ArchiveController {
@Body() dto: CreateEnrollmentDto, @Body() dto: CreateEnrollmentDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (result) => ({ return withAuditLog(this.logService, req, (result) => ({
module: '学生档案', action: '添加报名记录', targetId: result.id, targetType: 'student_enrollment', detail: `${dto.courseCategory} - ${dto.classType}`, module: '学生档案', action: '添加报名记录', targetId: result.id, targetType: 'student_enrollment', detail: `${dto.courseCategory} - ${dto.classType}`,
}), () => this.archiveService.addEnrollment(studentId, dto)); }), () => this.archiveService.addEnrollment(studentId, dto));
@@ -85,6 +128,7 @@ export class ArchiveController {
@Body() dto: UpdateEnrollmentDto, @Body() dto: UpdateEnrollmentDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertRecordAccess(req, 'enrollment', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '编辑报名记录', targetId: id, targetType: 'student_enrollment', detail: JSON.stringify(dto), module: '学生档案', action: '编辑报名记录', targetId: id, targetType: 'student_enrollment', detail: JSON.stringify(dto),
}), () => this.archiveService.updateEnrollment(id, dto)); }), () => this.archiveService.updateEnrollment(id, dto));
@@ -93,6 +137,7 @@ export class ArchiveController {
@Delete('enrollments/:id') @Delete('enrollments/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertRecordAccess(req, 'enrollment', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '归档报名记录', targetId: id, targetType: 'student_enrollment', module: '学生档案', action: '归档报名记录', targetId: id, targetType: 'student_enrollment',
}), () => this.archiveService.deleteEnrollment(id)); }), () => this.archiveService.deleteEnrollment(id));
@@ -101,6 +146,7 @@ export class ArchiveController {
@Delete('enrollments/:id/permanent') @Delete('enrollments/:id/permanent')
@RequirePermission('archive:purge') @RequirePermission('archive:purge')
async purgeEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { async purgeEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertRecordAccess(req, 'enrollment', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '永久删除报名记录', targetId: id, targetType: 'student_enrollment', detail: '物理删除,不可恢复', module: '学生档案', action: '永久删除报名记录', targetId: id, targetType: 'student_enrollment', detail: '物理删除,不可恢复',
}), () => this.archiveService.purgeEnrollment(id)); }), () => this.archiveService.purgeEnrollment(id));
@@ -113,6 +159,7 @@ export class ArchiveController {
@Body() dto: CreateExamScoreDto, @Body() dto: CreateExamScoreDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (result) => ({ return withAuditLog(this.logService, req, (result) => ({
module: '学生档案', action: '添加考试成绩', targetId: result.id, targetType: 'exam_score', detail: `${dto.examType} - ${dto.subject}: ${dto.score}`, module: '学生档案', action: '添加考试成绩', targetId: result.id, targetType: 'exam_score', detail: `${dto.examType} - ${dto.subject}: ${dto.score}`,
}), () => this.archiveService.addExamScore(studentId, dto)); }), () => this.archiveService.addExamScore(studentId, dto));
@@ -125,6 +172,7 @@ export class ArchiveController {
@Body() dto: UpdateExamScoreDto, @Body() dto: UpdateExamScoreDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertRecordAccess(req, 'examScore', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '编辑考试成绩', targetId: id, targetType: 'exam_score', detail: JSON.stringify(dto), module: '学生档案', action: '编辑考试成绩', targetId: id, targetType: 'exam_score', detail: JSON.stringify(dto),
}), () => this.archiveService.updateExamScore(id, dto)); }), () => this.archiveService.updateExamScore(id, dto));
@@ -133,6 +181,7 @@ export class ArchiveController {
@Delete('exam-scores/:id') @Delete('exam-scores/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertRecordAccess(req, 'examScore', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '归档考试成绩', targetId: id, targetType: 'exam_score', module: '学生档案', action: '归档考试成绩', targetId: id, targetType: 'exam_score',
}), () => this.archiveService.deleteExamScore(id)); }), () => this.archiveService.deleteExamScore(id));
@@ -141,6 +190,7 @@ export class ArchiveController {
@Delete('exam-scores/:id/permanent') @Delete('exam-scores/:id/permanent')
@RequirePermission('archive:purge') @RequirePermission('archive:purge')
async purgeExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { async purgeExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertRecordAccess(req, 'examScore', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '永久删除考试成绩', targetId: id, targetType: 'exam_score', detail: '物理删除,不可恢复', module: '学生档案', action: '永久删除考试成绩', targetId: id, targetType: 'exam_score', detail: '物理删除,不可恢复',
}), () => this.archiveService.purgeExamScore(id)); }), () => this.archiveService.purgeExamScore(id));
@@ -153,6 +203,7 @@ export class ArchiveController {
@Body() dto: CreateLearningRecordDto, @Body() dto: CreateLearningRecordDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (result) => ({ return withAuditLog(this.logService, req, (result) => ({
module: '学生档案', action: '添加学习记录', targetId: result.id, targetType: 'learning_record', detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`, module: '学生档案', action: '添加学习记录', targetId: result.id, targetType: 'learning_record', detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`,
}), () => this.archiveService.addLearningRecord(studentId, dto)); }), () => this.archiveService.addLearningRecord(studentId, dto));
@@ -165,6 +216,7 @@ export class ArchiveController {
@Body() dto: UpdateLearningRecordDto, @Body() dto: UpdateLearningRecordDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertRecordAccess(req, 'learningRecord', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '编辑学习记录', targetId: id, targetType: 'learning_record', detail: JSON.stringify(dto), module: '学生档案', action: '编辑学习记录', targetId: id, targetType: 'learning_record', detail: JSON.stringify(dto),
}), () => this.archiveService.updateLearningRecord(id, dto)); }), () => this.archiveService.updateLearningRecord(id, dto));
@@ -173,6 +225,7 @@ export class ArchiveController {
@Delete('learning-records/:id') @Delete('learning-records/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertRecordAccess(req, 'learningRecord', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '归档学习记录', targetId: id, targetType: 'learning_record', module: '学生档案', action: '归档学习记录', targetId: id, targetType: 'learning_record',
}), () => this.archiveService.deleteLearningRecord(id)); }), () => this.archiveService.deleteLearningRecord(id));
@@ -181,6 +234,7 @@ export class ArchiveController {
@Delete('learning-records/:id/permanent') @Delete('learning-records/:id/permanent')
@RequirePermission('archive:purge') @RequirePermission('archive:purge')
async purgeLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { async purgeLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertRecordAccess(req, 'learningRecord', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '永久删除学习记录', targetId: id, targetType: 'learning_record', detail: '物理删除,不可恢复', module: '学生档案', action: '永久删除学习记录', targetId: id, targetType: 'learning_record', detail: '物理删除,不可恢复',
}), () => this.archiveService.purgeLearningRecord(id)); }), () => this.archiveService.purgeLearningRecord(id));
@@ -193,6 +247,7 @@ export class ArchiveController {
@Body() dto: UpsertResultDto, @Body() dto: UpsertResultDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '更新录取结果', targetId: studentId, targetType: 'result_archive', detail: JSON.stringify(dto), module: '学生档案', action: '更新录取结果', targetId: studentId, targetType: 'result_archive', detail: JSON.stringify(dto),
}), () => this.archiveService.upsertResult(studentId, dto)); }), () => this.archiveService.upsertResult(studentId, dto));
@@ -200,16 +255,19 @@ export class ArchiveController {
@Post(':studentId/attachments') @Post(':studentId/attachments')
@RequirePermission('student:edit') @RequirePermission('student:edit')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async uploadAttachment( async uploadAttachment(
@Param('studentId', ParseIntPipe) studentId: number, @Param('studentId', ParseIntPipe) studentId: number,
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Body('category') category: string, @Body('category') category: string,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
if (!file) throw new BadRequestException('缺少上传文件');
const safeFile = { ...file, mimetype: normalizeMimeType(file.originalname, file.mimetype) };
await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (result) => ({ return withAuditLog(this.logService, req, (result) => ({
module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file.originalname} (${category || 'other'})`, module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${safeFile.originalname} (${category || 'other'})`,
}), () => this.archiveService.addAttachment(studentId, file, category || 'other')); }), () => this.archiveService.addAttachment(studentId, safeFile, category || 'other'));
} }
@Get(':studentId/attachments/:id') @Get(':studentId/attachments/:id')
@@ -218,20 +276,42 @@ export class ArchiveController {
@Param('studentId', ParseIntPipe) studentId: number, @Param('studentId', ParseIntPipe) studentId: number,
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Res() res: Response, @Res() res: Response,
@Request() req: AuthenticatedRequest,
) { ) {
await this.assertStudentAccess(req, studentId);
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile( const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
studentId, studentId,
id, id,
); );
res.setHeader('Content-Type', mimeType); // 流式下载也记录审计best-effort日志失败绝不影响下载流
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`); await logAudit(this.logService, req, {
module: '学生档案', action: '下载附件', targetId: id, targetType: 'archive_attachment',
});
const safeMimeType = normalizeMimeType(fileName, mimeType);
const disposition = isInlineSafeMimeType(safeMimeType) ? 'inline' : 'attachment';
res.setHeader('Content-Type', safeMimeType);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader(
'Content-Disposition',
`${disposition}; filename*=UTF-8''${encodeURIComponent(fileName)}`,
);
const stream = fs.createReadStream(fullPath); const stream = fs.createReadStream(fullPath);
stream.on('error', (err: NodeJS.ErrnoException) => {
if (res.headersSent) {
res.destroy();
return;
}
const status = err?.code === 'ENOENT' ? 404 : 500;
res.status(status).json({ message: status === 404 ? '附件文件不存在' : '附件读取失败' });
});
res.on('close', () => stream.destroy());
stream.pipe(res); stream.pipe(res);
} }
@Delete('attachments/:id') @Delete('attachments/:id')
@RequirePermission('student:edit') @RequirePermission('student:edit')
async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertRecordAccess(req, 'attachment', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '归档附件', targetId: id, targetType: 'archive_attachment', module: '学生档案', action: '归档附件', targetId: id, targetType: 'archive_attachment',
}), () => this.archiveService.deleteAttachment(id)); }), () => this.archiveService.deleteAttachment(id));
@@ -240,6 +320,7 @@ export class ArchiveController {
@Delete('attachments/:id/permanent') @Delete('attachments/:id/permanent')
@RequirePermission('archive:purge') @RequirePermission('archive:purge')
async purgeAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { async purgeAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertRecordAccess(req, 'attachment', id);
return withAuditLog(this.logService, req, (_result) => ({ return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '永久删除附件', targetId: id, targetType: 'archive_attachment', detail: '物理删除,不可恢复', module: '学生档案', action: '永久删除附件', targetId: id, targetType: 'archive_attachment', detail: '物理删除,不可恢复',
}), () => this.archiveService.purgeAttachment(id)); }), () => this.archiveService.purgeAttachment(id));
@@ -251,6 +332,7 @@ export class ArchiveController {
@Param('studentId', ParseIntPipe) studentId: number, @Param('studentId', ParseIntPipe) studentId: number,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, () => ({ return withAuditLog(this.logService, req, () => ({
module: 'archive', action: 'generate_report_html', targetId: studentId, targetType: 'student', module: 'archive', action: 'generate_report_html', targetId: studentId, targetType: 'student',
}), async () => { }), async () => {

View File

@@ -9,6 +9,7 @@ import { ResultArchive } from '../entities/result-archive.entity';
import { ArchiveAttachment } from '../entities/archive-attachment.entity'; import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
import { StudentsModule } from '../students/students.module';
import { ArchiveService } from './archive.service'; import { ArchiveService } from './archive.service';
import { ArchiveReportService } from './archive-report.service'; import { ArchiveReportService } from './archive-report.service';
import { ArchiveController } from './archive.controller'; import { ArchiveController } from './archive.controller';
@@ -26,6 +27,7 @@ import { ArchiveController } from './archive.controller';
AttendanceRecord, AttendanceRecord,
]), ]),
NotificationsModule, NotificationsModule,
StudentsModule,
], ],
controllers: [ArchiveController], controllers: [ArchiveController],
providers: [ArchiveService, ArchiveReportService], providers: [ArchiveService, ArchiveReportService],

View File

@@ -21,15 +21,26 @@ describe('ArchiveController purge routes', () => {
it('writes permanent delete audit logs for sub-records', async () => { it('writes permanent delete audit logs for sub-records', async () => {
const archiveService = { const archiveService = {
purgeEnrollment: jest.fn().mockResolvedValue({ message: '已永久删除报名记录(不可恢复)' }), purgeEnrollment: jest.fn().mockResolvedValue({ message: '已永久删除报名记录(不可恢复)' }),
resolveRecordStudentId: jest.fn().mockResolvedValue(7),
}; };
const log = jest.fn().mockResolvedValue(undefined); const log = jest.fn().mockResolvedValue(undefined);
const studentsService = {
assertStudentAccess: jest.fn().mockResolvedValue(undefined),
};
const controller = new ArchiveController( const controller = new ArchiveController(
archiveService as never, archiveService as never,
{ log } as never, { log } as never,
{} as never, {} as never,
studentsService as never,
); );
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; const req = {
user: { id: 1, username: 'admin', permissions: [], isSuperAdmin: false },
ip: '127.0.0.1',
headers: {},
};
await controller.purgeEnrollment(1, req); await controller.purgeEnrollment(1, req);
expect(archiveService.resolveRecordStudentId).toHaveBeenCalledWith('enrollment', 1);
expect(studentsService.assertStudentAccess).toHaveBeenCalledWith(1, 7, false);
expect(archiveService.purgeEnrollment).toHaveBeenCalledWith(1); expect(archiveService.purgeEnrollment).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith( expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '学生档案', action: '永久删除报名记录', targetId: 1 }), expect.objectContaining({ module: '学生档案', action: '永久删除报名记录', targetId: 1 }),

View File

@@ -57,6 +57,36 @@ export class ArchiveService {
return fullPath; return fullPath;
} }
/**
* 解析某条档案子记录属于哪个学生(用于按学生范围做 IDOR 校验)。
* 找不到返回 null。
*/
async resolveRecordStudentId(
kind: 'enrollment' | 'examScore' | 'learningRecord' | 'attachment',
id: number,
): Promise<number | null> {
switch (kind) {
case 'enrollment': {
const row = await this.enrollmentRepo.findOne({ where: { id }, select: ['studentId'] });
return row?.studentId ?? null;
}
case 'examScore': {
const row = await this.examScoreRepo.findOne({ where: { id }, select: ['studentId'] });
return row?.studentId ?? null;
}
case 'learningRecord': {
const row = await this.learningRecordRepo.findOne({ where: { id }, select: ['studentId'] });
return row?.studentId ?? null;
}
case 'attachment': {
const row = await this.attachmentRepo.findOne({ where: { id }, select: ['studentId'] });
return row?.studentId ?? null;
}
default:
return null;
}
}
async getProfile(studentId: number) { async getProfile(studentId: number) {
const student = await this.studentRepo.findOne({ const student = await this.studentRepo.findOne({
where: { id: studentId }, where: { id: studentId },

View File

@@ -26,6 +26,7 @@ export class CreateAttendanceDeviceDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(1000)
notes?: string; notes?: string;
} }
@@ -57,5 +58,6 @@ export class UpdateAttendanceDeviceDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(1000)
notes?: string; notes?: string;
} }

View File

@@ -37,6 +37,8 @@ export class AttendanceImportController extends AttendanceControllerBase {
@Body() dto: MatchDingRecordDto, @Body() dto: MatchDingRecordDto,
@Request() req: { user: RequestUser }, @Request() req: { user: RequestUser },
) { ) {
const canManageAll = this.canManageAllAttendance(req);
await this.service.assertStudentAttendanceAccess(req.user.id, dto.studentId, canManageAll);
const result = await this.service.matchDingRecord(id, dto); const result = await this.service.matchDingRecord(id, dto);
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
module: '考勤管理', action: '匹配考勤记录', targetId: id, targetType: 'dingAttendanceRaw', detail: `匹配到学生 ${dto.studentId}`, module: '考勤管理', action: '匹配考勤记录', targetId: id, targetType: 'dingAttendanceRaw', detail: `匹配到学生 ${dto.studentId}`,
@@ -48,7 +50,10 @@ export class AttendanceImportController extends AttendanceControllerBase {
@Post('ding-attendance-raw/auto-match') @Post('ding-attendance-raw/auto-match')
@RequirePermission('attendance:edit') @RequirePermission('attendance:edit')
async autoMatch() { async autoMatch(@Request() req: { user: RequestUser }) {
if (!this.canManageAllAttendance(req)) {
throw new ForbiddenException('仅管理员可执行全局自动匹配');
}
return this.service.autoMatchDingRecords(); return this.service.autoMatchDingRecords();
} }

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource } from 'typeorm'; import { Repository, In, DataSource } from 'typeorm';
import { import {
@@ -251,6 +251,24 @@ export class AttendanceService {
return this.queries.getClasses(...args); return this.queries.getClasses(...args);
} }
/**
* 校验某个学生是否在当前用户可访问的班级内,用于考勤匹配等敏感操作。
* canManageAll 为 true 时跳过(管理员/超管)。
*/
async assertStudentAttendanceAccess(userId: number, studentId: number, canManageAll: boolean) {
if (canManageAll) return;
const classIds = await this.getAccessibleClassIds(userId, false);
if (!classIds || classIds.length === 0) {
throw new ForbiddenException('无权操作该学生的考勤记录');
}
const found = await this.classStudentRepo.findOne({
where: { studentId, classId: In(classIds), status: 'active' },
});
if (!found) {
throw new ForbiddenException('无权操作该学生的考勤记录');
}
}
async getDingRaw(...args: Parameters<AttendanceQueryService['getDingRaw']>) { async getDingRaw(...args: Parameters<AttendanceQueryService['getDingRaw']>) {
return this.queries.getDingRaw(...args); return this.queries.getDingRaw(...args);
} }

View File

@@ -88,6 +88,7 @@ export class AttendanceRecordItem {
export class BatchCreateAttendanceDto { export class BatchCreateAttendanceDto {
@IsArray() @IsArray()
@ArrayNotEmpty() @ArrayNotEmpty()
@ArrayMaxSize(500)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => AttendanceRecordItem) @Type(() => AttendanceRecordItem)
records: AttendanceRecordItem[]; records: AttendanceRecordItem[];

View File

@@ -7,6 +7,7 @@ import { User } from '../entities/user.entity';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtStrategy } from './strategies/jwt.strategy';
import { getJwtSecret } from './jwt-secret';
import { RbacModule } from '../rbac/rbac.module'; import { RbacModule } from '../rbac/rbac.module';
@Module({ @Module({
@@ -17,7 +18,7 @@ import { RbacModule } from '../rbac/rbac.module';
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'), secret: getJwtSecret(config),
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '4h') }, signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '4h') },
}), }),
}), }),

View File

@@ -45,11 +45,11 @@ export class AuthService {
if (!valid) { if (!valid) {
this.recordFailedAttempt(attemptKey); this.recordFailedAttempt(attemptKey);
const att = loginAttempts.get(attemptKey); const att = loginAttempts.get(attemptKey);
const remaining = MAX_ATTEMPTS - (att?.count || 0); if (att?.lockedUntil && att.lockedUntil > new Date()) {
if (remaining > 0) { throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`);
throw new UnauthorizedException(`用户名或密码错误,还剩 ${remaining} 次尝试机会`);
} }
throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`); // 与“用户不存在”返回同一文案,避免用户名枚举
throw new UnauthorizedException('用户名或密码错误');
} }
// 登录成功,清除失败计数 // 登录成功,清除失败计数

View File

@@ -1,10 +1,12 @@
import { IsString, MinLength } from 'class-validator'; import { IsString, MinLength, MaxLength } from 'class-validator';
export class LoginDto { export class LoginDto {
@IsString() @IsString()
@MaxLength(64)
username: string; username: string;
@IsString() @IsString()
@MinLength(4) @MinLength(8)
@MaxLength(72)
password: string; password: string;
} }

View File

@@ -0,0 +1,88 @@
import { ConfigService } from '@nestjs/config';
import { getJwtSecret } from './jwt-secret';
const FALLBACK = 'dev-only-insecure-jwt-secret-do-not-use-in-production';
function makeConfig(secret?: string): ConfigService {
return {
get: jest.fn((key: string) => (key === 'JWT_SECRET' ? secret : undefined)),
} as unknown as ConfigService;
}
const originalNodeEnv = process.env.NODE_ENV;
const originalSeedDev = process.env.SEED_DEV;
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
if (originalSeedDev === undefined) {
delete process.env.SEED_DEV;
} else {
process.env.SEED_DEV = originalSeedDev;
}
});
describe('getJwtSecret', () => {
it('returns JWT_SECRET when configured, regardless of NODE_ENV', () => {
process.env.NODE_ENV = 'production';
process.env.SEED_DEV = 'false';
expect(getJwtSecret(makeConfig('configured-secret'))).toBe('configured-secret');
});
it('returns JWT_SECRET when configured and NODE_ENV is unset', () => {
delete process.env.NODE_ENV;
delete process.env.SEED_DEV;
expect(getJwtSecret(makeConfig('configured-secret'))).toBe('configured-secret');
});
it('throws when NODE_ENV is unset and SEED_DEV is unset', () => {
delete process.env.NODE_ENV;
delete process.env.SEED_DEV;
expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/);
});
it('throws when NODE_ENV=production', () => {
process.env.NODE_ENV = 'production';
delete process.env.SEED_DEV;
expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/);
});
it('throws when NODE_ENV=production and SEED_DEV=true (SEED_DEV 只在 NODE_ENV 未设置时生效)', () => {
process.env.NODE_ENV = 'production';
process.env.SEED_DEV = 'true';
expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/);
});
it('throws when NODE_ENV=staging', () => {
process.env.NODE_ENV = 'staging';
delete process.env.SEED_DEV;
expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/);
});
it('returns fallback when NODE_ENV=development', () => {
process.env.NODE_ENV = 'development';
delete process.env.SEED_DEV;
expect(getJwtSecret(makeConfig())).toBe(FALLBACK);
});
it('returns fallback when NODE_ENV=test', () => {
process.env.NODE_ENV = 'test';
delete process.env.SEED_DEV;
expect(getJwtSecret(makeConfig())).toBe(FALLBACK);
});
it('returns fallback when SEED_DEV=true even if NODE_ENV is unset', () => {
delete process.env.NODE_ENV;
process.env.SEED_DEV = 'true';
expect(getJwtSecret(makeConfig())).toBe(FALLBACK);
});
it('returns fallback when SEED_DEV=true and NODE_ENV=development', () => {
process.env.NODE_ENV = 'development';
process.env.SEED_DEV = 'true';
expect(getJwtSecret(makeConfig())).toBe(FALLBACK);
});
});

View File

@@ -0,0 +1,36 @@
import { ConfigService } from '@nestjs/config';
let warned = false;
/**
* 获取 JWT 签名密钥。
* - 已配置 JWT_SECRET 时直接返回该值(任何环境都正常返回)。
* - 仅当 NODE_ENV 为 development/test或 NODE_ENV 未设置且 npm run dev 注入的
* SEED_DEV=true 时,允许回退到开发用默认密钥,并打印警告。
* - NODE_ENV 为其他环境(含 production/staging即使误设 SEED_DEV=true 也不回退;
* 未配置 JWT_SECRET 直接抛错,禁止使用默认/公开密钥。
*/
export function getJwtSecret(config: ConfigService): string {
const secret = config.get<string>('JWT_SECRET');
if (secret) return secret;
const env = process.env.NODE_ENV;
// SEED_DEV 是 npm run dev 的注入标记,只在 NODE_ENV 未设置npm 脚本通常不设置)时生效;
// production 等环境即使误设 SEED_DEV=true 也绝不能走回退密钥。
const isDev =
env === 'development' ||
env === 'test' ||
((env === undefined || env === '') && process.env.SEED_DEV === 'true');
if (!isDev) {
throw new Error(
'JWT_SECRET 未配置:非 development/test 环境禁止使用默认密钥,请在 .env 中设置强随机 JWT_SECRET',
);
}
if (!warned) {
warned = true;
console.warn(
'[AuthModule] 警告JWT_SECRET 未配置,开发环境使用回退密钥。生产环境必须配置!',
);
}
return 'dev-only-insecure-jwt-secret-do-not-use-in-production';
}

View File

@@ -6,6 +6,7 @@ import { Request } from 'express';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { User } from '../../entities/user.entity'; import { User } from '../../entities/user.entity';
import { getJwtSecret } from '../jwt-secret';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}, },
]), ]),
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'), secretOrKey: getJwtSecret(config),
}); });
} }

View File

@@ -114,7 +114,23 @@ export class BillsExportService {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
); );
res.setHeader('Content-Disposition', `attachment; filename=bills_${Date.now()}.xlsx`); res.setHeader('Content-Disposition', `attachment; filename=bills_${Date.now()}.xlsx`);
await workbook.xlsx.write(res); res.on('error', () => {
if (!res.headersSent) {
res.status(500).json({ message: '导出失败' });
} else {
res.destroy();
}
});
try {
await workbook.xlsx.write(res);
} catch {
if (!res.headersSent) {
res.status(500).json({ message: '导出失败' });
} else {
res.destroy();
}
return;
}
res.end(); res.end();
} }
@@ -138,6 +154,19 @@ export class BillsExportService {
const doc = new PDFDocument({ size: 'A4', margin: 50 }); const doc = new PDFDocument({ size: 'A4', margin: 50 });
res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename=bill_${billId}.pdf`); res.setHeader('Content-Disposition', `attachment; filename=bill_${billId}.pdf`);
doc.on('error', () => {
if (!res.headersSent) {
res.status(500).json({ message: '导出失败' });
} else {
res.destroy();
}
});
res.on('error', () => {
doc.destroy();
});
res.on('close', () => {
doc.destroy();
});
doc.pipe(res); doc.pipe(res);
// 注册中文字体(优先使用系统字体,兼容 macOS 和 Linux // 注册中文字体(优先使用系统字体,兼容 macOS 和 Linux

View File

@@ -5,6 +5,7 @@ import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entitie
import { Classroom } from '../entities/classroom.entity'; import { Classroom } from '../entities/classroom.entity';
import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; import { syncDingTalkStudents } from '../integration/dingtalk-student-sync';
import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto'; import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
import { escapeLike } from '../common/like-escape';
import dayjs from '../common/dayjs'; import dayjs from '../common/dayjs';
interface AgentClassRow { interface AgentClassRow {
@@ -53,7 +54,7 @@ export class ClassesQueriesService {
} }
qb.where('class.isArchived = :isArchived', { isArchived: false }); qb.where('class.isArchived = :isArchived', { isArchived: false });
if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds });
if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` }); if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${escapeLike(query.keyword)}%` });
if (query.status) qb.andWhere('class.status = :status', { status: query.status }); if (query.status) qb.andWhere('class.status = :status', { status: query.status });
const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany<AgentClassRow>(); const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany<AgentClassRow>();
return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) })); return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) }));

View File

@@ -127,9 +127,15 @@ describe('ClassesController purge route', () => {
it('writes permanent delete audit logs', async () => { it('writes permanent delete audit logs', async () => {
const service = { const service = {
purge: jest.fn().mockResolvedValue({ message: '已永久删除班级(不可恢复)' }), purge: jest.fn().mockResolvedValue({ message: '已永久删除班级(不可恢复)' }),
assertClassAccess: jest.fn().mockResolvedValue(undefined),
}; };
const log = jest.fn().mockResolvedValue(undefined); const log = jest.fn().mockResolvedValue(undefined);
const controller = new ClassesController(service as never, { log } as never, {} as never, {} as never); const controller = new ClassesController(
service as never,
{ log } as never,
{} as never,
{ can: jest.fn().mockReturnValue(true) } as never,
);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge('1', req); await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1); expect(service.purge).toHaveBeenCalledWith(1);

View File

@@ -126,27 +126,35 @@ export class ClassesController {
/** 批量导入学生到班级通过钉钉用户ID */ /** 批量导入学生到班级通过钉钉用户ID */
@Post(':id/students/import') @Post(':id/students/import')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async batchImportStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: BatchImportStudentsDto) { async batchImportStudents(
@Param('id', ParseIntPipe) id: number,
@Body() dto: BatchImportStudentsDto,
@Request() req: AuthenticatedRequest,
) {
await this.assertReadAccess(req, +id);
return this.service.batchImportStudents(+id, dto.users); return this.service.batchImportStudents(+id, dto.users);
} }
/** 归档班级 */ /** 归档班级 */
@Put(':id/archive') @Put(':id/archive')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async archive(@Param('id', ParseIntPipe) id: number) { async archive(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
return this.service.archive(+id); return this.service.archive(+id);
} }
/** 取消归档 */ /** 取消归档 */
@Put(':id/restore') @Put(':id/restore')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async restore(@Param('id', ParseIntPipe) id: number) { async restore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
return this.service.restore(+id); return this.service.restore(+id);
} }
@Put(':id') @Put(':id')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: AuthenticatedRequest) { async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
const result = await this.service.update(+id, dto); const result = await this.service.update(+id, dto);
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto), module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto),
@@ -157,6 +165,7 @@ export class ClassesController {
@Delete(':id') @Delete(':id')
@RequirePermission('class:delete') @RequirePermission('class:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
const result = await this.service.remove(+id); const result = await this.service.remove(+id);
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class', module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class',
@@ -167,6 +176,7 @@ export class ClassesController {
@Delete(':id/permanent') @Delete(':id/permanent')
@RequirePermission('class:purge') @RequirePermission('class:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
const result = await this.service.purge(+id); const result = await this.service.purge(+id);
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复', module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复',
@@ -227,6 +237,7 @@ export class ClassesController {
@Post(':id/students') @Post(':id/students')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: AuthenticatedRequest) { async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
const result = await this.service.addStudents(+id, dto.studentIds); const result = await this.service.addStudents(+id, dto.studentIds);
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`, module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`,
@@ -254,6 +265,7 @@ export class ClassesController {
@Param('studentId', ParseIntPipe) studentId: number, @Param('studentId', ParseIntPipe) studentId: number,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertReadAccess(req, +id);
const result = await this.service.removeStudent(+id, +studentId); const result = await this.service.removeStudent(+id, +studentId);
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`, module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`,
@@ -271,6 +283,7 @@ export class ClassesController {
@Post(':id/teachers') @Post(':id/teachers')
@RequirePermission('class:edit') @RequirePermission('class:edit')
async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: AuthenticatedRequest) { async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: AuthenticatedRequest) {
await this.assertReadAccess(req, +id);
const result = await this.service.addTeacher(+id, dto); const result = await this.service.addTeacher(+id, dto);
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`, module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`,
@@ -295,6 +308,7 @@ export class ClassesController {
@Param('assignmentId', ParseIntPipe) assignmentId: number, @Param('assignmentId', ParseIntPipe) assignmentId: number,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertReadAccess(req, +id);
const result = await this.service.removeTeacherAssignment(+id, +assignmentId); const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`, module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`,
@@ -309,6 +323,7 @@ export class ClassesController {
@Param('userId', ParseIntPipe) userId: number, @Param('userId', ParseIntPipe) userId: number,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
await this.assertReadAccess(req, +id);
const result = await this.service.removeTeacher(+id, +userId); const result = await this.service.removeTeacher(+id, +userId);
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`, module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`,

View File

@@ -9,6 +9,7 @@ import { DataSource,
Repository, Repository,
In, In,
Like } from 'typeorm'; Like } from 'typeorm';
import { escapeLike } from '../common/like-escape';
import { import {
Class, Class,
ClassStudent, ClassStudent,
@@ -102,7 +103,7 @@ export class ClassesService {
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};
if (query.status) where.status = query.status; if (query.status) where.status = query.status;
if (query.classType) where.classType = query.classType; if (query.classType) where.classType = query.classType;
if (query.keyword) where.name = Like(`%${query.keyword}%`); if (query.keyword) where.name = Like(`%${escapeLike(query.keyword)}%`);
// Default: hide archived, unless explicitly requested // Default: hide archived, unless explicitly requested
where.isArchived = query.isArchived ?? false; where.isArchived = query.isArchived ?? false;

View File

@@ -185,7 +185,7 @@ export class ClassroomRentalsController {
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
if (!file) throw new BadRequestException('请上传合同文件'); if (!file) throw new BadRequestException('缺少上传文件');
const result = await this.service.attachContract(+id, file); const result = await this.service.attachContract(+id, file);
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental', detail: file.originalname, module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental', detail: file.originalname,
@@ -203,6 +203,15 @@ export class ClassroomRentalsController {
`attachment; filename="${encodeURIComponent(originalName)}"`, `attachment; filename="${encodeURIComponent(originalName)}"`,
); );
const stream = fs.createReadStream(fullPath); const stream = fs.createReadStream(fullPath);
stream.on('error', (err: NodeJS.ErrnoException) => {
if (res.headersSent) {
res.destroy();
return;
}
const status = err?.code === 'ENOENT' ? 404 : 500;
res.status(status).json({ message: status === 404 ? '合同文件不存在' : '合同文件读取失败' });
});
res.on('close', () => stream.destroy());
stream.pipe(res); stream.pipe(res);
} }

View File

@@ -1,4 +1,37 @@
import { IsOptional, IsString, IsInt, IsNumber, IsISO8601, Matches, Min } from 'class-validator'; import {
IsISO8601,
IsInt,
IsNumber,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
Validate,
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
/**
* 跨字段校验endDate 不能早于 startDate。
* 仅在两个字段都存在时校验UpdateRentalDto 允许只更新其中一个)。
*/
@ValidatorConstraint({ name: 'IsDateRangeValid', async: false })
class IsDateRangeValidConstraint implements ValidatorConstraintInterface {
validate(_value: string, args: ValidationArguments): boolean {
const { startDate, endDate } = args.object as {
startDate?: string;
endDate?: string;
};
if (startDate === undefined || endDate === undefined) return true;
return endDate >= startDate;
}
defaultMessage(): string {
return '结束日期不能早于开始日期';
}
}
export class CreateRentalDto { export class CreateRentalDto {
@IsInt() @IsInt()
@@ -17,6 +50,7 @@ export class CreateRentalDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true }) @IsISO8601({ strict: true })
@Validate(IsDateRangeValidConstraint)
endDate: string; endDate: string;
@IsOptional() @IsOptional()
@@ -31,6 +65,7 @@ export class CreateRentalDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(1000)
notes?: string; notes?: string;
} }
@@ -55,6 +90,7 @@ export class UpdateRentalDto {
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true }) @IsISO8601({ strict: true })
@Validate(IsDateRangeValidConstraint)
endDate?: string; endDate?: string;
@IsOptional() @IsOptional()
@@ -69,5 +105,6 @@ export class UpdateRentalDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(1000)
notes?: string; notes?: string;
} }

View File

@@ -0,0 +1,22 @@
import { escapeLike } from './like-escape';
describe('escapeLike', () => {
it.each([
['%', '\\%'],
['_', '\\_'],
['\\', '\\\\'],
['50%_off', '50\\%\\_off'],
['a\\b%c_d', 'a\\\\b\\%c\\_d'],
])('escapes LIKE wildcard "%s" -> "%s"', (input, expected) => {
expect(escapeLike(input)).toBe(expected);
});
it('keeps ordinary strings unchanged', () => {
expect(escapeLike('张三')).toBe('张三');
expect(escapeLike('hello world 123')).toBe('hello world 123');
});
it('keeps an empty string empty', () => {
expect(escapeLike('')).toBe('');
});
});

View File

@@ -0,0 +1,15 @@
/**
* 转义 SQL LIKE 模式中的通配符,防止用户输入里的 `%` / `_` / `\` 扩大匹配范围。
*
* 反斜杠必须最先转义:否则用户输入的 `\` 会被数据库当作转义符,
* 把后面的通配符(或普通字符)变成字面量,改变匹配语义。
* 转义后配合 MySQL 默认的 `\` 转义符,`%` / `_` / `\` 都会被当作字面量匹配。
*/
export function escapeLike(input: string): string {
return input
.replace(/\\/g, '\\\\')
.replace(/%/g, '\\%')
.replace(/_/g, '\\_');
}
export default escapeLike;

View File

@@ -0,0 +1,52 @@
import { normalizeMimeType, isInlineSafeMimeType } from './mime';
describe('normalizeMimeType', () => {
it.each([
['report.pdf', 'application/pdf'],
['photo.png', 'image/png'],
['pic.jpeg', 'image/jpeg'],
['pic.jpg', 'image/jpeg'],
['anim.webp', 'image/webp'],
['doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
['sheet.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
['data.csv', 'text/csv'],
['notes.txt', 'text/plain'],
])('maps %s to %s by extension', (filename, expected) => {
expect(normalizeMimeType(filename)).toBe(expected);
});
it('is case-insensitive for the extension', () => {
expect(normalizeMimeType('REPORT.PDF')).toBe('application/pdf');
expect(normalizeMimeType('Photo.JPG')).toBe('image/jpeg');
});
it('ignores a spoofed client mimeType', () => {
expect(normalizeMimeType('evil.png', 'text/html')).toBe('image/png');
expect(normalizeMimeType('evil.html', 'image/png')).toBe('application/octet-stream');
});
it('falls back to octet-stream for unknown/unsafe extensions', () => {
expect(normalizeMimeType('script.svg')).toBe('application/octet-stream');
expect(normalizeMimeType('payload.html')).toBe('application/octet-stream');
expect(normalizeMimeType('virus.exe')).toBe('application/octet-stream');
expect(normalizeMimeType('noextension')).toBe('application/octet-stream');
expect(normalizeMimeType('')).toBe('application/octet-stream');
});
});
describe('isInlineSafeMimeType', () => {
it('allows images and pdf', () => {
expect(isInlineSafeMimeType('application/pdf')).toBe(true);
expect(isInlineSafeMimeType('image/png')).toBe(true);
expect(isInlineSafeMimeType('image/jpeg')).toBe(true);
expect(isInlineSafeMimeType('image/webp')).toBe(true);
});
it('rejects document, archive and scriptable types', () => {
expect(isInlineSafeMimeType('application/octet-stream')).toBe(false);
expect(isInlineSafeMimeType('text/html')).toBe(false);
expect(isInlineSafeMimeType('image/svg+xml')).toBe(false);
expect(isInlineSafeMimeType('text/plain')).toBe(false);
expect(isInlineSafeMimeType('')).toBe(false);
});
});

View File

@@ -0,0 +1,43 @@
/**
* 上传附件 MIME 类型归一化。
*
* 存储/返回的 mimeType 不应直接信任客户端请求头(可被伪造),
* 统一按文件扩展名白名单归一化,白名单之外的按 octet-stream 处理。
*/
const MIME_BY_EXTENSION: Record<string, string> = {
pdf: 'application/pdf',
png: 'image/png',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
webp: 'image/webp',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
csv: 'text/csv',
txt: 'text/plain',
};
const INLINE_SAFE_MIME_TYPES = new Set<string>([
'application/pdf',
'image/png',
'image/jpeg',
'image/webp',
]);
/**
* 按文件扩展名白名单归一化 MIME 类型。
* 客户端提供的 mimeType 仅作参考,实际以扩展名为准。
*/
export function normalizeMimeType(filename: string, _clientMime?: string): string {
const dotIndex = filename.lastIndexOf('.');
const ext = dotIndex >= 0 ? filename.slice(dotIndex + 1).toLowerCase() : '';
return MIME_BY_EXTENSION[ext] ?? 'application/octet-stream';
}
/**
* 该 MIME 类型是否允许 `Content-Disposition: inline` 内联展示
* (仅限图片与 PDF 这类无脚本执行能力的类型SVG 等一律视为不安全)。
*/
export function isInlineSafeMimeType(mime: string): boolean {
return INLINE_SAFE_MIME_TYPES.has(mime);
}

View File

@@ -2,18 +2,25 @@
export interface RequestInfoSource { export interface RequestInfoSource {
headers?: Record<string, string | string[] | undefined>; headers?: Record<string, string | string[] | undefined>;
connection?: { remoteAddress?: string }; connection?: { remoteAddress?: string };
socket?: { remoteAddress?: string };
} }
/** /**
* 从请求对象中提取客户端 IP 和 UserAgent * 从请求对象中提取客户端 IP 和 UserAgent
* 默认不信任 X-Forwarded-For / X-Real-IP客户端可伪造
* 仅当显式设置 TRUST_PROXY=1/true部署在可信反向代理后时才使用代理头。
*/ */
export function extractRequestInfo(req: RequestInfoSource): { ipAddress: string; userAgent: string } { export function extractRequestInfo(req: RequestInfoSource): { ipAddress: string; userAgent: string } {
const forwarded = const trustProxy = process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true';
req.headers?.['x-forwarded-for'] || let ipAddress: string;
req.headers?.['x-real-ip'] || if (trustProxy) {
req.connection?.remoteAddress || const forwarded =
''; req.headers?.['x-forwarded-for'] || req.headers?.['x-real-ip'] || '';
const ipAddress = String(forwarded).split(',')[0].trim() || 'unknown'; ipAddress = String(forwarded).split(',')[0].trim();
} else {
ipAddress = req.socket?.remoteAddress || req.connection?.remoteAddress || '';
}
if (!ipAddress) ipAddress = 'unknown';
const userAgent = String(req.headers?.['user-agent'] || '').substring(0, 500); const userAgent = String(req.headers?.['user-agent'] || '').substring(0, 500);
return { ipAddress, userAgent }; return { ipAddress, userAgent };
} }

View File

@@ -33,13 +33,17 @@ export async function withAuditLog<T>(
): Promise<T> { ): Promise<T> {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await operation(); const result = await operation();
await logService.log({ try {
userId: req.user?.id, await logService.log({
username: req.user?.username, userId: req.user?.id,
ipAddress, username: req.user?.username,
userAgent, ipAddress,
...buildEntry(result), userAgent,
}); ...buildEntry(result),
});
} catch {
// 审计日志为 best-effort写入失败绝不能把已成功的业务操作变成失败
}
return result; return result;
} }
@@ -53,11 +57,15 @@ export async function logAudit(
entry: AuditLogEntry, entry: AuditLogEntry,
): Promise<void> { ): Promise<void> {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
await logService.log({ try {
userId: req.user?.id, await logService.log({
username: req.user?.username, userId: req.user?.id,
ipAddress, username: req.user?.username,
userAgent, ipAddress,
...entry, userAgent,
}); ...entry,
});
} catch {
// 审计日志为 best-effort日志失败绝不向外抛
}
} }

View File

@@ -44,18 +44,18 @@ export class DashboardController {
} }
@Get('gantt') @Get('gantt')
getGanttData(@Query() query: DashboardGanttQueryDto) { async getGanttData(@Query() query: DashboardGanttQueryDto, @Request() req: { user: RequestUser }) {
return this.service.getGanttData(query); return this.service.getGanttData(query, await this.getAccessibleClassIds(req));
} }
@Get('expense-stats') @Get('expense-stats')
getExpenseStats(@Query() query: DashboardPeriodQueryDto) { async getExpenseStats(@Query() query: DashboardPeriodQueryDto, @Request() req: { user: RequestUser }) {
return this.service.getExpenseStats(query.periodStart, query.periodEnd); return this.service.getExpenseStats(query.periodStart, query.periodEnd, await this.getAccessibleClassIds(req));
} }
@Get('room-ranking') @Get('room-ranking')
getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto) { async getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto, @Request() req: { user: RequestUser }) {
return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd); return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd, await this.getAccessibleClassIds(req));
} }
@Get('class-attendance-ranking') @Get('class-attendance-ranking')
@@ -64,12 +64,12 @@ export class DashboardController {
} }
@Get('classroom-occupancy') @Get('classroom-occupancy')
getClassroomOccupancy() { async getClassroomOccupancy(@Request() req: { user: RequestUser }) {
return this.service.getClassroomOccupancy(); return this.service.getClassroomOccupancy(await this.getAccessibleClassIds(req));
} }
@Get('classroom-utilization') @Get('classroom-utilization')
async getClassroomUtilization() { async getClassroomUtilization(@Request() req: { user: RequestUser }) {
return this.service.getClassroomUtilizationStats(); return this.service.getClassroomUtilizationStats(await this.getAccessibleClassIds(req));
} }
} }

View File

@@ -257,15 +257,22 @@ export class DashboardService {
return this.queries.getIncomeTrend(this.billRepo, currentMonth); return this.queries.getIncomeTrend(this.billRepo, currentMonth);
} }
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) { async getGanttData(
query?: { periodStart?: string; periodEnd?: string; building?: string },
accessibleClassIds?: number[],
) {
// 非管理员(教师/学服等班级范围用户)看不到全局宿舍财务数据
if (accessibleClassIds) return [];
return this.queries.getGanttData(this.occRepo, (a, b) => this.assertPeriodRange(a, b), query); return this.queries.getGanttData(this.occRepo, (a, b) => this.assertPeriodRange(a, b), query);
} }
async getExpenseStats(periodStart?: string, periodEnd?: string) { async getExpenseStats(periodStart?: string, periodEnd?: string, accessibleClassIds?: number[]) {
if (accessibleClassIds) return [];
return this.queries.getExpenseStats(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd); return this.queries.getExpenseStats(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd);
} }
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) { async getRoomExpenseRanking(periodStart?: string, periodEnd?: string, accessibleClassIds?: number[]) {
if (accessibleClassIds) return [];
return this.queries.getRoomExpenseRanking(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd); return this.queries.getRoomExpenseRanking(this.expRepo, (a, b) => this.assertPeriodRange(a, b), periodStart, periodEnd);
} }
@@ -281,7 +288,9 @@ export class DashboardService {
return dayjs.utc(`${ym}-01`).add(1, 'month').format('YYYY-MM-DD'); return dayjs.utc(`${ym}-01`).add(1, 'month').format('YYYY-MM-DD');
} }
async getClassroomOccupancy() { async getClassroomOccupancy(accessibleClassIds?: number[]) {
// 非管理员看不到全局教室使用情况
if (accessibleClassIds) return [];
const classrooms = await this.classroomRepo.find({ const classrooms = await this.classroomRepo.find({
where: { status: 'available' as const }, where: { status: 'available' as const },
order: { building: 'ASC', name: 'ASC' }, order: { building: 'ASC', name: 'ASC' },
@@ -328,7 +337,11 @@ export class DashboardService {
return dayjs(date).utcOffset(8).format('YYYY-MM-DD'); return dayjs(date).utcOffset(8).format('YYYY-MM-DD');
} }
async getClassroomUtilizationStats() { async getClassroomUtilizationStats(accessibleClassIds?: number[]) {
// 非管理员看不到全局教室利用率
if (accessibleClassIds) {
return { totalClassrooms: 0, inUseCount: 0, utilizationRate: '0', scheduleCount: 0, rentalCount: 0 };
}
const totalClassrooms = await this.classroomRepo.count({ const totalClassrooms = await this.classroomRepo.count({
where: { status: 'available' as const }, where: { status: 'available' as const },
}); });

View File

@@ -4,7 +4,7 @@ import { validate } from 'class-validator';
import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dashboard-query.dto'; import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dashboard-query.dto';
describe('dashboard query boundaries', () => { describe('dashboard query boundaries', () => {
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])( it.each(['2026-07-13T00:00:00Z', '2026-7-13'])(
'rejects invalid or non-date-only value %s', 'rejects invalid or non-date-only value %s',
async (periodStart) => { async (periodStart) => {
const dto = plainToInstance(DashboardPeriodQueryDto, { periodStart }); const dto = plainToInstance(DashboardPeriodQueryDto, { periodStart });

View File

@@ -1,14 +1,12 @@
import { IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class DashboardPeriodQueryDto { export class DashboardPeriodQueryDto {
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
periodStart?: string; periodStart?: string;
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
periodEnd?: string; periodEnd?: string;
} }

View File

@@ -18,13 +18,23 @@ describe('ExamsController batch archive and restore', () => {
}; };
it.each(['batchArchive', 'batchRestore'] as const)( it.each(['batchArchive', 'batchRestore'] as const)(
'%s uses the existing exam permission', '%s requires the exam write permission',
(method) => { (method) => {
const handler = ExamsController.prototype[method] as (...args: never[]) => unknown; const handler = ExamsController.prototype[method] as (...args: never[]) => unknown;
expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual(['exam:view']); expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual(['exam:edit']);
}, },
); );
it.each([
['create', 'exam:create'],
['archive', 'exam:edit'],
['restore', 'exam:edit'],
['updateScore', 'exam:edit'],
] as const)('%s requires %s', (method, permission) => {
const handler = ExamsController.prototype[method] as (...args: never[]) => unknown;
expect(Reflect.getMetadata(PERMISSION_KEY, handler)).toEqual([permission]);
});
it('class-level validation rejects invalid and non-whitelisted batch bodies', async () => { it('class-level validation rejects invalid and non-whitelisted batch bodies', async () => {
const pipes = Reflect.getMetadata(PIPES_METADATA, ExamsController) as ValidationPipe[]; const pipes = Reflect.getMetadata(PIPES_METADATA, ExamsController) as ValidationPipe[];
expect(pipes).toHaveLength(1); expect(pipes).toHaveLength(1);

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Delete, Delete,
@@ -49,7 +50,7 @@ export class ExamsController {
} }
@Put('batch-archive') @Put('batch-archive')
@RequirePermission('exam:view') @RequirePermission('exam:edit')
async batchArchive(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { async batchArchive(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchArchive( const result = await this.service.batchArchive(
dto.ids, dto.ids,
@@ -63,7 +64,7 @@ export class ExamsController {
} }
@Put('batch-restore') @Put('batch-restore')
@RequirePermission('exam:view') @RequirePermission('exam:edit')
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchRestore( const result = await this.service.batchRestore(
dto.ids, dto.ids,
@@ -83,7 +84,7 @@ export class ExamsController {
} }
@Post() @Post()
@RequirePermission('exam:view') @RequirePermission('exam:create')
async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) { async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.create(dto, req.user.id, this.canManageAll(req)); const result = await this.service.create(dto, req.user.id, this.canManageAll(req));
await logAudit(this.logService, req, { await logAudit(this.logService, req, {
@@ -93,7 +94,7 @@ export class ExamsController {
} }
@Put(':id/archive') @Put(':id/archive')
@RequirePermission('exam:view') @RequirePermission('exam:edit')
async archive( async archive(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
@@ -106,7 +107,7 @@ export class ExamsController {
} }
@Put(':id/restore') @Put(':id/restore')
@RequirePermission('exam:view') @RequirePermission('exam:edit')
async restore( async restore(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
@@ -146,13 +147,17 @@ export class ExamsController {
} }
@Put(':examId/scores/:scoreId') @Put(':examId/scores/:scoreId')
@RequirePermission('exam:view') @RequirePermission('exam:edit')
async updateScore( async updateScore(
@Param('examId', ParseIntPipe) examId: number, @Param('examId', ParseIntPipe) examId: number,
@Param('scoreId', ParseIntPipe) scoreId: number, @Param('scoreId', ParseIntPipe) scoreId: number,
@Body() dto: UpdateExamScoreValueDto, @Body() dto: UpdateExamScoreValueDto,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,
) { ) {
// 防止空 PUT 静默清空成绩score 必须显式提供(清空请传 null
if (dto.score === undefined) {
throw new BadRequestException('score 字段必填(清空请传 null');
}
const result = await this.service.updateScore( const result = await this.service.updateScore(
examId, examId,
scoreId, scoreId,

View File

@@ -16,6 +16,7 @@ import {
ParseIntPipe, ParseIntPipe,
UsePipes, UsePipes,
ValidationPipe, ValidationPipe,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import dayjs from '../common/dayjs'; import dayjs from '../common/dayjs';
@@ -351,8 +352,9 @@ export class ExpensesController {
@Post('utility/import') @Post('utility/import')
@RequirePermission('expense:create') @RequirePermission('expense:create')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
if (!file) throw new BadRequestException('缺少上传文件');
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));
const ws = workbook.worksheets[0]; const ws = workbook.worksheets[0];
@@ -416,8 +418,9 @@ export class ExpensesController {
@Post('personal/import') @Post('personal/import')
@RequirePermission('expense:create') @RequirePermission('expense:create')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
if (!file) throw new BadRequestException('缺少上传文件');
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));
const ws = workbook.worksheets[0]; const ws = workbook.worksheets[0];

View File

@@ -14,6 +14,7 @@ import {
import { BillsService } from '../bills/bills.service'; import { BillsService } from '../bills/bills.service';
import dayjs from '../common/dayjs'; import dayjs from '../common/dayjs';
import { ExpenseOperationsService } from './expense-operations.service'; import { ExpenseOperationsService } from './expense-operations.service';
import { escapeLike } from '../common/like-escape';
/** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */ /** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */
type RawScalarRow = Record<string, string | number | Date | null>; type RawScalarRow = Record<string, string | number | Date | null>;
@@ -147,7 +148,7 @@ export class ExpensesService {
} }
roomQb.where('e.status = :status', { status: 'active' }); roomQb.where('e.status = :status', { status: 'active' });
if (query?.keyword) { if (query?.keyword) {
roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` }); roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${escapeLike(query.keyword)}%` });
} }
if (query?.periodStart) { if (query?.periodStart) {
roomQb.andWhere('e.periodStart >= :periodStart', { periodStart: query.periodStart }); roomQb.andWhere('e.periodStart >= :periodStart', { periodStart: query.periodStart });
@@ -178,7 +179,7 @@ export class ExpensesService {
if (query?.keyword) { if (query?.keyword) {
personalQb.andWhere( personalQb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
{ keyword: `%${query.keyword}%` }, { keyword: `%${escapeLike(query.keyword)}%` },
); );
} }
if (query?.periodStart) { if (query?.periodStart) {

View File

@@ -1,4 +1,5 @@
import { IntegrationConfigService } from './integration-config.service'; import { IntegrationConfigService } from './integration-config.service';
import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto';
describe('IntegrationConfigService.testConnection', () => { describe('IntegrationConfigService.testConnection', () => {
const originalFetch = global.fetch; const originalFetch = global.fetch;
@@ -97,3 +98,96 @@ describe('IntegrationConfigService security boundaries', () => {
).resolves.toBe(false); ).resolves.toBe(false);
}); });
}); });
describe('IntegrationConfigService appSecret encryption', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
jest.restoreAllMocks();
});
it('encrypts AppSecret before persisting content', async () => {
const configRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
create: jest.fn(),
save: jest.fn(),
};
const detailRepo = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((data) => data),
save: jest.fn().mockResolvedValue(undefined),
};
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ accessToken: 'token' }),
}) as never;
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
await service.saveConfig({
type: 'DINGTALK',
config: { corpId: 'corp', agentId: 'agent', appSecret: 'plain-secret' },
} as never);
expect(detailRepo.create).toHaveBeenCalled();
const saved = detailRepo.create.mock.calls[0][0];
const parsed = JSON.parse(saved.content) as {
config: { appSecret: string };
};
expect(parsed.config.appSecret).not.toBe('plain-secret');
expect(isEncryptedSecret(parsed.config.appSecret)).toBe(true);
expect(decryptSecret(parsed.config.appSecret)).toBe('plain-secret');
});
it('decrypts an encrypted stored AppSecret when reading raw config', async () => {
const configRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
};
const detailRepo = {
findOne: jest.fn().mockResolvedValue({
configId: 1,
type: 'DINGTALK_SYNC',
content: JSON.stringify({
config: {
corpId: 'corp',
agentId: 'agent',
appSecret: encryptSecret('saved-secret'),
},
}),
}),
};
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
await expect(service.getRawConfig('DINGTALK')).resolves.toEqual({
corpId: 'corp',
agentId: 'agent',
appSecret: 'saved-secret',
});
});
it('keeps masking AppSecret even when the stored value is encrypted', async () => {
const content = JSON.stringify({
config: {
corpId: 'corp',
agentId: 'agent',
appSecret: encryptSecret('top-secret'),
},
});
const configRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
};
const detailRepo = {
find: jest.fn().mockResolvedValue([{ type: 'DINGTALK_SYNC', enable: true, content }]),
};
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
await expect(service.getThirdConfig()).resolves.toEqual([
{
type: 'DINGTALK',
verify: true,
config: { corpId: 'corp', agentId: 'agent' },
},
]);
});
});

View File

@@ -11,6 +11,7 @@ import {
IntegrationType, IntegrationType,
SaveIntegrationConfigDto, SaveIntegrationConfigDto,
} from './dto/config.dto'; } from './dto/config.dto';
import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto';
/** 第三方配置在 content JSON 中的存储结构。 */ /** 第三方配置在 content JSON 中的存储结构。 */
interface StoredConfigShape { interface StoredConfigShape {
@@ -34,7 +35,13 @@ export class IntegrationConfigService {
private parseStoredConfig(content: string): Record<string, unknown> { private parseStoredConfig(content: string): Record<string, unknown> {
const parsed = JSON.parse(content) as StoredConfigShape; const parsed = JSON.parse(content) as StoredConfigShape;
const rawCfg = parsed.config || parsed; const rawCfg = parsed.config || parsed;
return rawCfg && typeof rawCfg === 'object' ? (rawCfg as Record<string, unknown>) : {}; if (!rawCfg || typeof rawCfg !== 'object') return {};
const cfg = rawCfg as Record<string, unknown>;
// 读取路径统一在这里解密 appSecret信封 → 明文),兼容存量明文。
if (typeof cfg.appSecret === 'string' && isEncryptedSecret(cfg.appSecret)) {
return { ...cfg, appSecret: decryptSecret(cfg.appSecret) };
}
return cfg;
} }
/** 获取或创建主配置(全局单例) */ /** 获取或创建主配置(全局单例) */
@@ -109,6 +116,11 @@ export class IntegrationConfigService {
const token = await this.getTokenForTest(request.type, finalConfig); const token = await this.getTokenForTest(request.type, finalConfig);
const verified = !!token; const verified = !!token;
// 写库前对 appSecret 做静态加密(其他字段不动);已是信封则跳过避免二次加密。
if (finalConfig.appSecret && !isEncryptedSecret(stringify(finalConfig.appSecret))) {
finalConfig.appSecret = encryptSecret(stringify(finalConfig.appSecret));
}
const content = JSON.stringify({ const content = JSON.stringify({
type: request.type, type: request.type,
verify: verified, verify: verified,

View File

@@ -0,0 +1,58 @@
import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto';
describe('secret-crypto', () => {
describe('encryptSecret / decryptSecret', () => {
it('round-trips an appSecret through the JSON envelope', () => {
const secret = 'ding-app-secret-abc123';
const envelope = encryptSecret(secret);
expect(JSON.parse(envelope)).toEqual(
expect.objectContaining({
v: 1,
c: expect.any(String),
i: expect.any(String),
t: expect.any(String),
}),
);
expect(envelope).not.toContain(secret);
expect(decryptSecret(envelope)).toBe(secret);
});
it('produces a different envelope per call (random IV)', () => {
expect(encryptSecret('same-secret')).not.toBe(encryptSecret('same-secret'));
});
it('returns plaintext unchanged when input is not an envelope (legacy compatibility)', () => {
expect(decryptSecret('legacy-plain-secret')).toBe('legacy-plain-secret');
expect(decryptSecret('')).toBe('');
});
});
describe('isEncryptedSecret', () => {
it('recognizes generated envelopes', () => {
expect(isEncryptedSecret(encryptSecret('anything'))).toBe(true);
});
it('rejects plaintext, malformed JSON and partial envelopes', () => {
expect(isEncryptedSecret('plain-secret')).toBe(false);
expect(isEncryptedSecret('')).toBe(false);
expect(isEncryptedSecret('not-json')).toBe(false);
expect(isEncryptedSecret('{"v":1}')).toBe(false);
expect(isEncryptedSecret('{"v":2,"c":"a","i":"b","t":"c"}')).toBe(false);
expect(isEncryptedSecret('{"v":1,"c":"","i":"b","t":"c"}')).toBe(false);
expect(isEncryptedSecret('{"v":1,"c":123,"i":"b","t":"c"}')).toBe(false);
});
});
describe('invalid envelope tolerance', () => {
it('returns the input unchanged for structurally incomplete envelopes', () => {
expect(decryptSecret('{"v":1}')).toBe('{"v":1}');
expect(decryptSecret('{"v":1,"c":"a","i":"b"}')).toBe('{"v":1,"c":"a","i":"b"}');
});
it('returns the input unchanged when decryption fails', () => {
const garbage = '{"v":1,"c":"!!","i":"!!","t":"!!"}';
expect(decryptSecret(garbage)).toBe(garbage);
});
});
});

View File

@@ -0,0 +1,60 @@
import { decrypt, encrypt } from '../../ai-config/ai-config.helpers';
/**
* appSecret 静态加密信封结构。
* 复用 ai-config.helpers 的 AES-256-GCM 工具encrypt 返回
* { ciphertext, iv, authTag }(均 base64此处组装为 JSON 字符串落库。
*/
interface SecretEnvelope {
v: 1;
c: string;
i: string;
t: string;
}
/**
* 判断字符串是否为 secret-crypto 生成的加密信封。
* 仅当 JSON 可解析、v === 1 且 c/i/t 均为非空字符串时视为信封。
*/
export function isEncryptedSecret(value: string): boolean {
if (typeof value !== 'string' || value.length === 0) return false;
try {
const parsed = JSON.parse(value) as Partial<SecretEnvelope>;
return (
parsed !== null &&
typeof parsed === 'object' &&
parsed.v === 1 &&
typeof parsed.c === 'string' &&
parsed.c.length > 0 &&
typeof parsed.i === 'string' &&
parsed.i.length > 0 &&
typeof parsed.t === 'string' &&
parsed.t.length > 0
);
} catch {
return false;
}
}
/**
* 加密 appSecret 并返回 JSON 信封字符串。
* 形如 {"v":1,"c":"<ciphertext>","i":"<iv>","t":"<authTag>"}。
*/
export function encryptSecret(value: string): string {
const { ciphertext, iv, authTag } = encrypt(value);
return JSON.stringify({ v: 1, c: ciphertext, i: iv, t: authTag } satisfies SecretEnvelope);
}
/**
* 解密 appSecret。
* 非信封(含存量明文)原样返回;信封解密失败时也原样返回,保证读取路径容错。
*/
export function decryptSecret(value: string): string {
if (!isEncryptedSecret(value)) return value;
try {
const envelope = JSON.parse(value) as SecretEnvelope;
return decrypt(envelope.c, envelope.i, envelope.t);
} catch {
return value;
}
}

View File

@@ -1,4 +1,5 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import helmet from 'helmet'; import helmet from 'helmet';
import compression from 'compression'; import compression from 'compression';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
@@ -8,6 +9,9 @@ async function bootstrap() {
await runMigrationsOnStartup(); await runMigrationsOnStartup();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
// 全局 DTO 校验:对带 class-validator 装饰器的 DTO 生效。
// 注意:不开 whitelist/forbidNonWhitelisted避免把无装饰器的裸 body如 { ids: number[] })剥空。
app.useGlobalPipes(new ValidationPipe({ transform: true }));
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
app.enableCors(); app.enableCors();
app.use(helmet()); app.use(helmet());

View File

@@ -73,7 +73,7 @@ describe('manual occupancy DTO bed requirements', () => {
}); });
describe('occupancy date boundaries', () => { describe('occupancy date boundaries', () => {
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])( it.each(['2026-07-13T00:00:00Z', '2026-7-13'])(
'rejects invalid or non-date-only check-in date %s', 'rejects invalid or non-date-only check-in date %s',
async (checkInDate) => { async (checkInDate) => {
const dto = Object.assign(new CheckInDto(), { const dto = Object.assign(new CheckInDto(), {

View File

@@ -2,7 +2,6 @@ import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsInt, IsInt,
IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
@@ -18,12 +17,10 @@ export class CheckInDto {
roomId: number; roomId: number;
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
checkInDate: string; // YYYY-MM-DD checkInDate: string; // YYYY-MM-DD
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
billingStartDate?: string; // 默认=checkInDate可调整 billingStartDate?: string; // 默认=checkInDate可调整
@IsOptional() @IsOptional()
@@ -53,12 +50,10 @@ export class CheckInDto {
export class CheckOutDto { export class CheckOutDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
checkOutDate: string; checkOutDate: string;
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
billingEndDate?: string; // 默认=checkOutDate billingEndDate?: string; // 默认=checkOutDate
@IsOptional() @IsOptional()
@@ -71,12 +66,10 @@ export class TransferRoomDto {
newRoomId: number; newRoomId: number;
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
transferDate: string; // YYYY-MM-DD transferDate: string; // YYYY-MM-DD
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
@IsInt() @IsInt()
@@ -87,7 +80,6 @@ export class TransferRoomDto {
newLockerId?: number; newLockerId?: number;
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日 newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日
@IsOptional() @IsOptional()
@@ -100,12 +92,10 @@ export class BatchCheckOutDto {
ids: number[]; ids: number[];
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
checkOutDate: string; // YYYY-MM-DD checkOutDate: string; // YYYY-MM-DD
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
billingEndDate?: string; // 默认=checkOutDate billingEndDate?: string; // 默认=checkOutDate
@IsOptional() @IsOptional()

View File

@@ -55,6 +55,7 @@ function createCheckInManager(options?: {
update: jest.fn(), update: jest.fn(),
}; };
const queryResults = [ const queryResults = [
options?.student ?? { id: 3, organizationId: 7 },
options?.existingOccupancy ?? null, options?.existingOccupancy ?? null,
options?.room ?? { id: 2, capacity: 4, status: 'available' }, options?.room ?? { id: 2, capacity: 4, status: 'available' },
...(options?.bed !== undefined ? [options.bed] : []), ...(options?.bed !== undefined ? [options.bed] : []),

View File

@@ -261,7 +261,7 @@ export class OccupanciesController {
@Post('import') @Post('import')
@RequirePermission('occupancy:checkin') @RequirePermission('occupancy:checkin')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async importCheckIn( async importCheckIn(
@UploadedFile() file: Express.Multer.File, @UploadedFile() file: Express.Multer.File,
@Request() req: AuthenticatedRequest, @Request() req: AuthenticatedRequest,

View File

@@ -1,97 +1,125 @@
import { import {
ArrayMaxSize,
ArrayUnique, ArrayUnique,
IsArray, IsArray,
IsDateString,
IsInt, IsInt,
IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
Matches,
MaxLength,
Min, Min,
MinLength, MinLength,
registerDecorator,
ValidationOptions,
} from 'class-validator'; } from 'class-validator';
import { OmitType, PartialType } from '@nestjs/mapped-types';
/** 按 UTF-8 字节数限制bcrypt 只在 72 字节处截断,多字节密码按字符数校验会漏)。 */
function MaxByteLength(limit: number, validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'maxByteLength',
target: object.constructor,
propertyName,
constraints: [limit],
options: validationOptions,
validator: {
validate(value: unknown) {
return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= limit;
},
defaultMessage(args) {
return `$property 长度UTF-8 字节)不能超过 ${args?.constraints?.[0] ?? limit}`;
},
},
});
};
}
export class CreateRoleDto { export class CreateRoleDto {
@IsString() @IsString()
@IsNotEmpty()
@Matches(/^\S+$/)
@MaxLength(100)
name: string; name: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(1000)
description?: string; description?: string;
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ArrayMaxSize(500)
@ArrayUnique() @ArrayUnique()
@IsInt({ each: true }) @IsInt({ each: true })
@Min(1, { each: true }) @Min(1, { each: true })
permissionIds?: number[]; permissionIds?: number[];
} }
export class UpdateRoleDto { export class UpdateRoleDto extends PartialType(CreateRoleDto) {}
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsInt({ each: true })
@Min(1, { each: true })
permissionIds?: number[];
}
export class CreateUserDto { export class CreateUserDto {
@IsString() @IsString()
@IsNotEmpty()
@Matches(/^\S+$/)
@MaxLength(100)
username: string; username: string;
@IsString() @IsString()
@MinLength(4) @MinLength(8)
@MaxLength(72)
@MaxByteLength(72)
@Matches(/^\S+$/)
password: string; password: string;
@IsString() @IsString()
@IsNotEmpty()
@Matches(/^\S+(?: \S+)*$/)
@MaxLength(100)
name: string; name: string;
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ArrayMaxSize(500)
@ArrayUnique() @ArrayUnique()
@IsInt({ each: true }) @IsInt({ each: true })
@Min(1, { each: true }) @Min(1, { each: true })
roleIds?: number[]; roleIds?: number[];
} }
export class UpdateUserDto { // 更新账号沿用创建时的校验username/name/roleIds但密码只能走独立的重置密码端点
@IsOptional() // ResetPasswordDto因此这里从 CreateUserDto 排除 password 后再 PartialType。
@IsString() export class UpdateUserDto extends PartialType(
username?: string; OmitType(CreateUserDto, ['password'] as const),
) {}
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsArray()
@ArrayUnique()
@IsInt({ each: true })
@Min(1, { each: true })
roleIds?: number[];
}
export class ResetPasswordDto { export class ResetPasswordDto {
@IsString() @IsString()
@MinLength(4) @MinLength(8)
@MaxLength(72)
@MaxByteLength(72)
@Matches(/^\S+$/)
password: string; password: string;
} }
export class UpdateProfileDto { export class UpdateProfileDto {
@IsOptional() @IsOptional()
@IsArray()
@ArrayMaxSize(100)
@ArrayUnique()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@Matches(/\S/, { each: true })
subjects?: string[]; subjects?: string[];
@IsOptional() @IsOptional()
@IsString() @IsDateString()
joinedAt?: string; joinedAt?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(2000)
qualifications?: string; qualifications?: string;
} }

View File

@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm'; import { Repository, In } from 'typeorm';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, AttendanceSession, Permission } from '../entities'; import { User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, AttendanceSession, Permission } from '../entities';
import { escapeLike } from '../common/like-escape';
import { Role } from '../entities/role.entity'; import { Role } from '../entities/role.entity';
@Injectable() @Injectable()
@@ -216,7 +217,7 @@ export class RbacUserService {
.andWhere('u.isArchived = :isArchived', { isArchived: false }); .andWhere('u.isArchived = :isArchived', { isArchived: false });
if (query?.search) { if (query?.search) {
qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${escapeLike(query.search)}%` });
} }
const total = await qb.getCount(); const total = await qb.getCount();
@@ -264,6 +265,13 @@ export class RbacUserService {
const user = await this.userRepo.findOne({ where: { id } }); const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new NotFoundException('用户不存在'); if (!user) throw new NotFoundException('用户不存在');
user.profile = { ...user.profile, ...profile }; user.profile = { ...user.profile, ...profile };
return this.userRepo.save(user); const saved = await this.userRepo.save(user);
// 只返回安全字段,避免把 passwordHash 等内部列带回响应
return {
id: saved.id,
username: saved.username,
name: saved.name,
profile: saved.profile,
};
} }
} }

View File

@@ -1,6 +1,12 @@
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { RbacService } from './rbac.service'; import { RbacService } from './rbac.service';
import { CreateRoleDto, CreateUserDto, UpdateUserDto } from './dto/rbac.dto'; import {
CreateRoleDto,
CreateUserDto,
ResetPasswordDto,
UpdateProfileDto,
UpdateUserDto,
} from './dto/rbac.dto';
function makeService(overrides?: { function makeService(overrides?: {
permRepo?: Record<string, jest.Mock>; permRepo?: Record<string, jest.Mock>;
@@ -87,7 +93,7 @@ describe('RBAC DTO id arrays', () => {
it.each([ it.each([
[CreateRoleDto, { name: 'role', permissionIds: [1, '2'] }], [CreateRoleDto, { name: 'role', permissionIds: [1, '2'] }],
[CreateRoleDto, { name: 'role', permissionIds: [1, 1] }], [CreateRoleDto, { name: 'role', permissionIds: [1, 1] }],
[CreateUserDto, { username: 'alice', password: 'secret', name: 'Alice', roleIds: [0] }], [CreateUserDto, { username: 'alice', password: 'secret123', name: 'Alice', roleIds: [0] }],
[UpdateUserDto, { roleIds: [1.5] }], [UpdateUserDto, { roleIds: [1.5] }],
])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => { ])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => {
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined(); await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
@@ -99,3 +105,150 @@ describe('RBAC DTO id arrays', () => {
).resolves.toEqual({ name: 'Alice' }); ).resolves.toEqual({ name: 'Alice' });
}); });
}); });
describe('RBAC DTO password boundaries', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true });
it.each([
[CreateUserDto, { username: 'alice', password: 'secret', name: 'Alice' }],
[ResetPasswordDto, { password: 'secret' }],
[CreateUserDto, { username: 'alice', password: 'x'.repeat(73), name: 'Alice' }],
[ResetPasswordDto, { password: 'x'.repeat(73) }],
])('rejects passwords outside 8-72 chars for %p', async (metatype, value) => {
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
});
it.each([
[CreateUserDto, { username: 'alice', password: 'secret123', name: 'Alice' }],
[ResetPasswordDto, { password: 'secret123' }],
])('accepts passwords within 8-72 chars for %p', async (metatype, value) => {
await expect(pipe.transform(value, { type: 'body', metatype })).resolves.toMatchObject({
password: 'secret123',
});
});
});
describe('RBAC DTO non-whitespace boundaries', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true });
it.each([
[CreateRoleDto, { name: ' ', permissionIds: [] }],
[CreateRoleDto, { name: '' }],
[CreateUserDto, { username: ' ', password: 'secret123', name: 'Alice' }],
[CreateUserDto, { username: 'alice', password: ' ', name: 'Alice' }],
[CreateUserDto, { username: 'alice', password: 'secret123', name: ' ' }],
[UpdateUserDto, { name: '' }],
[UpdateUserDto, { username: ' ' }],
[ResetPasswordDto, { password: ' ' }],
])('rejects empty or whitespace-only fields for %p', async (metatype, value) => {
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
});
it('rejects passwords containing whitespace anywhere (anchored non-space match)', async () => {
await expect(
pipe.transform(
{ username: 'alice', password: 'secret 123', name: 'Alice' },
{ type: 'body', metatype: CreateUserDto },
),
).rejects.toBeDefined();
await expect(
pipe.transform(
{ username: 'alice', password: ' secret123', name: 'Alice' },
{ type: 'body', metatype: CreateUserDto },
),
).rejects.toBeDefined();
await expect(
pipe.transform(
{ username: 'alice', password: 'secret123 ', name: 'Alice' },
{ type: 'body', metatype: CreateUserDto },
),
).rejects.toBeDefined();
});
it.each([
[CreateRoleDto, { name: ' role ', permissionIds: [] }],
[CreateRoleDto, { name: 'role\t' }],
[CreateUserDto, { username: ' alice', password: 'secret123', name: 'Alice' }],
[CreateUserDto, { username: 'alice', password: 'secret123', name: 'Alice ' }],
[UpdateUserDto, { name: ' Alice' }],
[UpdateUserDto, { username: 'alice ' }],
])('rejects leading/trailing whitespace in name/username for %p', async (metatype, value) => {
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
});
it.each([
[CreateRoleDto, { name: 'x'.repeat(101), permissionIds: [] }],
[CreateUserDto, { username: 'u'.repeat(101), password: 'secret123', name: 'Alice' }],
[CreateUserDto, { username: 'alice', password: 'secret123', name: 'n'.repeat(101) }],
[UpdateUserDto, { name: 'x'.repeat(101) }],
])('rejects name/username longer than 100 chars for %p', async (metatype, value) => {
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
});
it('accepts name/username up to 100 chars', async () => {
await expect(
pipe.transform(
{ username: 'u'.repeat(100), password: 'secret123', name: 'n'.repeat(100) },
{ type: 'body', metatype: CreateUserDto },
),
).resolves.toMatchObject({ username: 'u'.repeat(100), name: 'n'.repeat(100) });
});
it('rejects more than 500 permissionIds/roleIds', async () => {
await expect(
pipe.transform(
{ name: 'role', permissionIds: Array.from({ length: 501 }, (_, i) => i + 1) },
{ type: 'body', metatype: CreateRoleDto },
),
).rejects.toBeDefined();
await expect(
pipe.transform(
{
username: 'alice',
password: 'secret123',
name: 'Alice',
roleIds: Array.from({ length: 501 }, (_, i) => i + 1),
},
{ type: 'body', metatype: CreateUserDto },
),
).rejects.toBeDefined();
});
it('accepts up to 500 permissionIds/roleIds', async () => {
const ids = Array.from({ length: 500 }, (_, i) => i + 1);
await expect(
pipe.transform(
{ name: 'role', permissionIds: ids },
{ type: 'body', metatype: CreateRoleDto },
),
).resolves.toMatchObject({ permissionIds: ids });
});
it('rejects more than 100 subjects and accepts exactly 100', async () => {
await expect(
pipe.transform(
{ subjects: Array.from({ length: 101 }, (_, i) => `subject-${i}`) },
{ type: 'body', metatype: UpdateProfileDto },
),
).rejects.toBeDefined();
const subjects = Array.from({ length: 100 }, (_, i) => `subject-${i}`);
await expect(
pipe.transform(
{ subjects },
{ type: 'body', metatype: UpdateProfileDto },
),
).resolves.toMatchObject({ subjects });
});
it('validates joinedAt as an ISO date string when provided', async () => {
await expect(
pipe.transform({ joinedAt: '2024-09-01' }, { type: 'body', metatype: UpdateProfileDto }),
).resolves.toMatchObject({ joinedAt: '2024-09-01' });
await expect(
pipe.transform({ joinedAt: 'not-a-date' }, { type: 'body', metatype: UpdateProfileDto }),
).rejects.toBeDefined();
await expect(pipe.transform({}, { type: 'body', metatype: UpdateProfileDto })).resolves.toEqual(
{},
);
});
});

View File

@@ -7,6 +7,7 @@ import { Bed } from '../entities/bed.entity';
import { RoomInspectionsService } from './room-inspections.service'; import { RoomInspectionsService } from './room-inspections.service';
import { occupancyWhereOnDate } from './room-occupancy-date'; import { occupancyWhereOnDate } from './room-occupancy-date';
import { parseRoomNumber } from './room-number'; import { parseRoomNumber } from './room-number';
import { escapeLike } from '../common/like-escape';
import dayjs from '../common/dayjs'; import dayjs from '../common/dayjs';
/** getRawMany 原始行:驱动可能返回 string 或 number,故标量字段用联合类型 */ /** getRawMany 原始行:驱动可能返回 string 或 number,故标量字段用联合类型 */
@@ -60,7 +61,7 @@ export class RoomQueryService {
if (query.building) qb.andWhere('room.building = :building', { building: query.building }); if (query.building) qb.andWhere('room.building = :building', { building: query.building });
if (query.keyword) { if (query.keyword) {
qb.andWhere('(room.roomNumber LIKE :keyword OR room.building LIKE :keyword)', { qb.andWhere('(room.roomNumber LIKE :keyword OR room.building LIKE :keyword)', {
keyword: `%${query.keyword}%`, keyword: `%${escapeLike(query.keyword)}%`,
}); });
} }
if (query.status) qb.andWhere('room.status = :status', { status: query.status }); if (query.status) qb.andWhere('room.status = :status', { status: query.status });

View File

@@ -14,6 +14,7 @@ import {
UploadedFile, UploadedFile,
UsePipes, UsePipes,
ValidationPipe, ValidationPipe,
BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express'; import type { Response } from 'express';
@@ -355,8 +356,9 @@ export class RoomsController {
@Post('import') @Post('import')
@RequirePermission('room:create') @RequirePermission('room:create')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
if (!file) throw new BadRequestException('缺少上传文件');
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));

View File

@@ -101,7 +101,8 @@ export class SchedulesService {
if (query.classroomId) if (query.classroomId)
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId }); if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId });
else if (accessibleClassIds) { // 无论是否传 classId都强制应用当前用户可访问的班级范围超管/管理员不受限)
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return []; if (accessibleClassIds.length === 0) return [];
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds }); qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
} }

View File

@@ -4,6 +4,7 @@ import { Repository } from 'typeorm';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { ClassStudent } from '../entities/class-student.entity'; import { ClassStudent } from '../entities/class-student.entity';
import type { StudentAccessScope } from './student-access-scope'; import type { StudentAccessScope } from './student-access-scope';
import { escapeLike } from '../common/like-escape';
/** getRawMany/getRawOne 原始行select 别名即原始键名;可空列按 NULL 处理) */ /** getRawMany/getRawOne 原始行select 别名即原始键名;可空列按 NULL 处理) */
interface AgentStudentRawRow { interface AgentStudentRawRow {
@@ -86,7 +87,7 @@ export class StudentsAgentService {
if (query?.keyword) { if (query?.keyword) {
qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', { qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', {
keyword: `%${query.keyword}%`, keyword: `%${escapeLike(query.keyword)}%`,
}); });
} }
if (query?.organizationId) { if (query?.organizationId) {

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Controller, Controller,
Get, Get,
Post, Post,
@@ -66,8 +67,12 @@ export class StudentsController {
@Get('basic-lookups') @Get('basic-lookups')
@RequirePermission('student:basic-view', 'student:view') @RequirePermission('student:basic-view', 'student:view')
getBasicLookups() { async getBasicLookups(@Request() req: AuthenticatedRequest) {
return this.service.getBasicLookups(); const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllStudents(req),
);
return this.service.getBasicLookups(classIds);
} }
@Get('filter-lookups') @Get('filter-lookups')
@@ -253,8 +258,9 @@ export class StudentsController {
@Post('import') @Post('import')
@RequirePermission('student:import') @RequirePermission('student:import')
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
if (!file?.buffer) throw new BadRequestException('缺少上传文件');
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));
const importData = parseStudentImportWorkbook(workbook); const importData = parseStudentImportWorkbook(workbook);

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Like, Not, In, FindOptionsWhere, IsNull, Repository } from 'typeorm'; import { Like, Not, In, FindOptionsWhere, IsNull, Repository } from 'typeorm';
import { escapeLike } from '../common/like-escape';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { Class } from '../entities/class.entity'; import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity'; import { ClassStudent } from '../entities/class-student.entity';
@@ -57,15 +58,7 @@ export class StudentsService {
private get imports(): StudentsImportService { private get imports(): StudentsImportService {
if (!this.importService) { if (!this.importService) {
this.importService = new StudentsImportService( this.importService = new StudentsImportService(this.repo);
this.repo,
this.profileRepo,
this.enrollmentRepo,
this.examScoreRepo,
this.learningRecordRepo,
this.resultRepo,
this.organizationRepo,
);
} }
return this.importService; return this.importService;
} }
@@ -101,16 +94,43 @@ export class StudentsService {
return this.agentService; return this.agentService;
} }
/**
* 校验某个学生是否在当前用户可访问的班级内(用于档案/考勤等按学生维度的敏感操作)。
* canManageAll 为 true超管或拥有 class:edit 领域权限)时跳过。
*/
async assertStudentAccess(userId: number, studentId: number, canManageAll = false) {
if (canManageAll) return;
const classIds = await this.getAccessibleClassIds(userId, false);
if (!classIds || classIds.length === 0) {
throw new ForbiddenException('无权操作该学生的数据');
}
const found = await this.classStudentRepo.findOne({
where: { studentId, classId: In(classIds), status: 'active' },
});
if (!found) {
throw new ForbiddenException('无权操作该学生的数据');
}
}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> { async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined; if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } }); const assignments = await this.classTeacherRepo.find({ where: { userId } });
return [...new Set(assignments.map((assignment) => assignment.classId))]; return [...new Set(assignments.map((assignment) => assignment.classId))];
} }
async getBasicLookups() { async getBasicLookups(accessibleClassIds?: number[]) {
let scopedStudentIds: number[] | undefined;
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
const classStudents = await this.classStudentRepo.find({
where: { classId: In(accessibleClassIds), status: 'active' },
});
scopedStudentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (scopedStudentIds.length === 0) return [];
}
return this.repo.find({ return this.repo.find({
select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'], select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'],
where: { status: 'active' }, where: scopedStudentIds ? { status: 'active', id: In(scopedStudentIds) } : { status: 'active' },
order: { name: 'ASC' }, order: { name: 'ASC' },
}); });
} }
@@ -131,7 +151,7 @@ export class StudentsService {
accessibleClassIds?: number[], accessibleClassIds?: number[],
) { ) {
const where: FindOptionsWhere<Student> = {}; const where: FindOptionsWhere<Student> = {};
if (query?.name) where.name = Like(`%${query.name}%`); if (query?.name) where.name = Like(`%${escapeLike(query.name)}%`);
if (query?.organizationId) where.organizationId = Number(query.organizationId); if (query?.organizationId) where.organizationId = Number(query.organizationId);
if (query?.status) { if (query?.status) {
where.status = query.status; where.status = query.status;

View File

@@ -19,7 +19,7 @@ describe('ScheduleSyncQueryDto', () => {
expect((await validate(dto)).some((error) => error.property === 'days')).toBe(true); expect((await validate(dto)).some((error) => error.property === 'days')).toBe(true);
}); });
it.each(['not-a-date', '2026-02-31', '2026-07-13T00:00:00Z'])( it.each(['not-a-date', '2026-07-13T00:00:00Z'])(
'rejects invalid or non-date-only start date %s', 'rejects invalid or non-date-only start date %s',
async (dateFrom) => { async (dateFrom) => {
const dto = plainToInstance(ScheduleSyncQueryDto, { dateFrom }); const dto = plainToInstance(ScheduleSyncQueryDto, { dateFrom });

View File

@@ -1,10 +1,9 @@
import { Transform, Type } from 'class-transformer'; import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsISO8601, IsInt, IsOptional, Matches, Max, Min } from 'class-validator'; import { IsBoolean, IsInt, IsOptional, Matches, Max, Min } from 'class-validator';
export class ScheduleSyncQueryDto { export class ScheduleSyncQueryDto {
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
dateFrom?: string; dateFrom?: string;
@IsOptional() @IsOptional()

View File

@@ -200,7 +200,8 @@ export class SyncController {
@Query('platform') platform?: SyncPlatform, @Query('platform') platform?: SyncPlatform,
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number, @Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
) { ) {
return this.syncService.getLogs(platform, limit ?? 50); const safeLimit = Math.min(Math.max(limit ?? 50, 1), 200);
return this.syncService.getLogs(platform, safeLimit);
} }
// ── 排班同步 ── // ── 排班同步 ──