diff --git a/apps/server/package.json b/apps/server/package.json
index 896065cf..41cb0e17 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -14,6 +14,7 @@
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"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:fix": "npm run lint -- --fix",
"typecheck": "tsc -p tsconfig.build.json --noEmit",
diff --git a/apps/server/scripts/encrypt-integration-secrets.ts b/apps/server/scripts/encrypt-integration-secrets.ts
new file mode 100644
index 00000000..c4fe2382
--- /dev/null
+++ b/apps/server/scripts/encrypt-integration-secrets.ts
@@ -0,0 +1,67 @@
+///
+
+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;
+}
+
+async function main(): Promise {
+ // 主动检查加密密钥:缺失时 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;
+});
diff --git a/apps/server/src/ai-chat/ai-chat.controller.ts b/apps/server/src/ai-chat/ai-chat.controller.ts
index 49339f9f..4c1101b1 100644
--- a/apps/server/src/ai-chat/ai-chat.controller.ts
+++ b/apps/server/src/ai-chat/ai-chat.controller.ts
@@ -1,4 +1,5 @@
import {
+ BadRequestException,
Body,
Controller,
Delete,
@@ -108,6 +109,7 @@ export class AiChatController {
@Req() req: AuthenticatedRequest,
@UploadedFile() file: Express.Multer.File,
) {
+ if (!file) throw new BadRequestException('缺少上传文件');
const attachment = await this.attachmentService.upload(req.user.id, file);
return { success: true, data: this.attachmentService.serialize(attachment) };
}
diff --git a/apps/server/src/ai-chat/ai-review.import-basic.ts b/apps/server/src/ai-chat/ai-review.import-basic.ts
index e01f894a..5199cbfe 100644
--- a/apps/server/src/ai-chat/ai-review.import-basic.ts
+++ b/apps/server/src/ai-chat/ai-review.import-basic.ts
@@ -7,6 +7,7 @@ import { RoomsService } from '../rooms/rooms.service';
import type { AiReviewSection } from './entities/ai-review.entity';
import { MAX_CAPACITY, normalizePhone } from './ai-review.shared';
import { resolveOrganizationId } from './ai-review.enrich';
+import { addDaysToDateOnly } from '../common/china-time';
export async function importStudents(
section: AiReviewSection | undefined,
@@ -172,10 +173,5 @@ export function normalizeFloor(raw: unknown): number | null {
}
export function nextDay(date: string): string {
- const parsed = new Date(`${date}T00:00:00+08:00`);
- 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}`;
+ return addDaysToDateOnly(date, 1);
}
diff --git a/apps/server/src/ai-chat/dto/ai-chat.dto.ts b/apps/server/src/ai-chat/dto/ai-chat.dto.ts
index 6b16241e..da4adab9 100644
--- a/apps/server/src/ai-chat/dto/ai-chat.dto.ts
+++ b/apps/server/src/ai-chat/dto/ai-chat.dto.ts
@@ -1,18 +1,5 @@
import { Type } from 'class-transformer';
-import {
- ArrayMaxSize,
- IsArray,
- IsIn,
- IsInt,
- IsNotEmpty,
- IsObject,
- IsOptional,
- IsString,
- IsUUID,
- Max,
- MaxLength,
- Min,
-} from 'class-validator';
+import { ArrayMaxSize, IsArray, IsIn, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID, Max, MaxLength, Min, registerDecorator } from 'class-validator';
import { REASONING_EFFORT_LEVELS } from '../../ai-config/dto/ai-config.dto';
export class CreateConversationDto {
@@ -89,11 +76,39 @@ export class EditMessageDto {
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 {
@IsUUID()
clientRequestId: string;
@IsObject()
+ @MaxFormValues()
values: Record;
@IsOptional()
diff --git a/apps/server/src/archive/archive.controller.ts b/apps/server/src/archive/archive.controller.ts
index f9afe1fa..118b9e71 100644
--- a/apps/server/src/archive/archive.controller.ts
+++ b/apps/server/src/archive/archive.controller.ts
@@ -12,8 +12,12 @@ import {
UploadedFile,
Res,
ParseIntPipe,
+ BadRequestException,
+ NotFoundException,
+ UnauthorizedException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
+import { normalizeMimeType, isInlineSafeMimeType } from '../common/mime';
import type { Request as ExpressRequest, Response } from 'express';
import * as fs from 'fs';
import { ArchiveReportService } from './archive-report.service';
@@ -30,11 +34,17 @@ import {
} from './dto/archive.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
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 { StudentsService } from '../students/students.service';
interface AuthenticatedRequest extends ExpressRequest {
- user?: { id: number; username?: string };
+ user?: {
+ id: number;
+ username?: string;
+ permissions?: string[];
+ isSuperAdmin?: boolean;
+ };
}
@UseGuards(JwtAuthGuard)
@@ -44,11 +54,42 @@ export class ArchiveController {
private readonly archiveService: ArchiveService,
private readonly logService: OperationLogsService,
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')
@RequirePermission('student:view')
async getProfile(@Param('studentId', ParseIntPipe) studentId: number, @Request() req: AuthenticatedRequest) {
+ await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '查看档案', targetId: studentId, targetType: 'archive',
}), () => this.archiveService.getProfile(studentId));
@@ -61,6 +102,7 @@ export class ArchiveController {
@Body() dto: UpsertProfileDto,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '更新档案信息', targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto),
}), () => this.archiveService.upsertProfile(studentId, dto));
@@ -73,6 +115,7 @@ export class ArchiveController {
@Body() dto: CreateEnrollmentDto,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (result) => ({
module: '学生档案', action: '添加报名记录', targetId: result.id, targetType: 'student_enrollment', detail: `${dto.courseCategory} - ${dto.classType}`,
}), () => this.archiveService.addEnrollment(studentId, dto));
@@ -85,6 +128,7 @@ export class ArchiveController {
@Body() dto: UpdateEnrollmentDto,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertRecordAccess(req, 'enrollment', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '编辑报名记录', targetId: id, targetType: 'student_enrollment', detail: JSON.stringify(dto),
}), () => this.archiveService.updateEnrollment(id, dto));
@@ -93,6 +137,7 @@ export class ArchiveController {
@Delete('enrollments/:id')
@RequirePermission('student:edit')
async deleteEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
+ await this.assertRecordAccess(req, 'enrollment', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '归档报名记录', targetId: id, targetType: 'student_enrollment',
}), () => this.archiveService.deleteEnrollment(id));
@@ -101,6 +146,7 @@ export class ArchiveController {
@Delete('enrollments/:id/permanent')
@RequirePermission('archive:purge')
async purgeEnrollment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
+ await this.assertRecordAccess(req, 'enrollment', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '永久删除报名记录', targetId: id, targetType: 'student_enrollment', detail: '物理删除,不可恢复',
}), () => this.archiveService.purgeEnrollment(id));
@@ -113,6 +159,7 @@ export class ArchiveController {
@Body() dto: CreateExamScoreDto,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (result) => ({
module: '学生档案', action: '添加考试成绩', targetId: result.id, targetType: 'exam_score', detail: `${dto.examType} - ${dto.subject}: ${dto.score}`,
}), () => this.archiveService.addExamScore(studentId, dto));
@@ -125,6 +172,7 @@ export class ArchiveController {
@Body() dto: UpdateExamScoreDto,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertRecordAccess(req, 'examScore', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '编辑考试成绩', targetId: id, targetType: 'exam_score', detail: JSON.stringify(dto),
}), () => this.archiveService.updateExamScore(id, dto));
@@ -133,6 +181,7 @@ export class ArchiveController {
@Delete('exam-scores/:id')
@RequirePermission('student:edit')
async deleteExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
+ await this.assertRecordAccess(req, 'examScore', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '归档考试成绩', targetId: id, targetType: 'exam_score',
}), () => this.archiveService.deleteExamScore(id));
@@ -141,6 +190,7 @@ export class ArchiveController {
@Delete('exam-scores/:id/permanent')
@RequirePermission('archive:purge')
async purgeExamScore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
+ await this.assertRecordAccess(req, 'examScore', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '永久删除考试成绩', targetId: id, targetType: 'exam_score', detail: '物理删除,不可恢复',
}), () => this.archiveService.purgeExamScore(id));
@@ -153,6 +203,7 @@ export class ArchiveController {
@Body() dto: CreateLearningRecordDto,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (result) => ({
module: '学生档案', action: '添加学习记录', targetId: result.id, targetType: 'learning_record', detail: `${dto.recordType}: ${dto.content.substring(0, 50)}`,
}), () => this.archiveService.addLearningRecord(studentId, dto));
@@ -165,6 +216,7 @@ export class ArchiveController {
@Body() dto: UpdateLearningRecordDto,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertRecordAccess(req, 'learningRecord', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '编辑学习记录', targetId: id, targetType: 'learning_record', detail: JSON.stringify(dto),
}), () => this.archiveService.updateLearningRecord(id, dto));
@@ -173,6 +225,7 @@ export class ArchiveController {
@Delete('learning-records/:id')
@RequirePermission('student:edit')
async deleteLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
+ await this.assertRecordAccess(req, 'learningRecord', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '归档学习记录', targetId: id, targetType: 'learning_record',
}), () => this.archiveService.deleteLearningRecord(id));
@@ -181,6 +234,7 @@ export class ArchiveController {
@Delete('learning-records/:id/permanent')
@RequirePermission('archive:purge')
async purgeLearningRecord(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
+ await this.assertRecordAccess(req, 'learningRecord', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '永久删除学习记录', targetId: id, targetType: 'learning_record', detail: '物理删除,不可恢复',
}), () => this.archiveService.purgeLearningRecord(id));
@@ -193,6 +247,7 @@ export class ArchiveController {
@Body() dto: UpsertResultDto,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '更新录取结果', targetId: studentId, targetType: 'result_archive', detail: JSON.stringify(dto),
}), () => this.archiveService.upsertResult(studentId, dto));
@@ -200,16 +255,19 @@ export class ArchiveController {
@Post(':studentId/attachments')
@RequirePermission('student:edit')
- @UseInterceptors(FileInterceptor('file'))
+ @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async uploadAttachment(
@Param('studentId', ParseIntPipe) studentId: number,
@UploadedFile() file: Express.Multer.File,
@Body('category') category: string,
@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) => ({
- module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file.originalname} (${category || 'other'})`,
- }), () => this.archiveService.addAttachment(studentId, file, category || 'other'));
+ module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${safeFile.originalname} (${category || 'other'})`,
+ }), () => this.archiveService.addAttachment(studentId, safeFile, category || 'other'));
}
@Get(':studentId/attachments/:id')
@@ -218,20 +276,42 @@ export class ArchiveController {
@Param('studentId', ParseIntPipe) studentId: number,
@Param('id', ParseIntPipe) id: number,
@Res() res: Response,
+ @Request() req: AuthenticatedRequest,
) {
+ await this.assertStudentAccess(req, studentId);
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(
studentId,
id,
);
- res.setHeader('Content-Type', mimeType);
- res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);
+ // 流式下载也记录审计:best-effort,日志失败绝不影响下载流
+ 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);
+ 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);
}
@Delete('attachments/:id')
@RequirePermission('student:edit')
async deleteAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
+ await this.assertRecordAccess(req, 'attachment', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '归档附件', targetId: id, targetType: 'archive_attachment',
}), () => this.archiveService.deleteAttachment(id));
@@ -240,6 +320,7 @@ export class ArchiveController {
@Delete('attachments/:id/permanent')
@RequirePermission('archive:purge')
async purgeAttachment(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
+ await this.assertRecordAccess(req, 'attachment', id);
return withAuditLog(this.logService, req, (_result) => ({
module: '学生档案', action: '永久删除附件', targetId: id, targetType: 'archive_attachment', detail: '物理删除,不可恢复',
}), () => this.archiveService.purgeAttachment(id));
@@ -251,6 +332,7 @@ export class ArchiveController {
@Param('studentId', ParseIntPipe) studentId: number,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertStudentAccess(req, studentId);
return withAuditLog(this.logService, req, () => ({
module: 'archive', action: 'generate_report_html', targetId: studentId, targetType: 'student',
}), async () => {
diff --git a/apps/server/src/archive/archive.module.ts b/apps/server/src/archive/archive.module.ts
index d755a89b..44655029 100644
--- a/apps/server/src/archive/archive.module.ts
+++ b/apps/server/src/archive/archive.module.ts
@@ -9,6 +9,7 @@ import { ResultArchive } from '../entities/result-archive.entity';
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { NotificationsModule } from '../notifications/notifications.module';
+import { StudentsModule } from '../students/students.module';
import { ArchiveService } from './archive.service';
import { ArchiveReportService } from './archive-report.service';
import { ArchiveController } from './archive.controller';
@@ -26,6 +27,7 @@ import { ArchiveController } from './archive.controller';
AttendanceRecord,
]),
NotificationsModule,
+ StudentsModule,
],
controllers: [ArchiveController],
providers: [ArchiveService, ArchiveReportService],
diff --git a/apps/server/src/archive/archive.purge.controller.spec.ts b/apps/server/src/archive/archive.purge.controller.spec.ts
index 81c64d7f..9e185405 100644
--- a/apps/server/src/archive/archive.purge.controller.spec.ts
+++ b/apps/server/src/archive/archive.purge.controller.spec.ts
@@ -21,15 +21,26 @@ describe('ArchiveController purge routes', () => {
it('writes permanent delete audit logs for sub-records', async () => {
const archiveService = {
purgeEnrollment: jest.fn().mockResolvedValue({ message: '已永久删除报名记录(不可恢复)' }),
+ resolveRecordStudentId: jest.fn().mockResolvedValue(7),
};
const log = jest.fn().mockResolvedValue(undefined);
+ const studentsService = {
+ assertStudentAccess: jest.fn().mockResolvedValue(undefined),
+ };
const controller = new ArchiveController(
archiveService as never,
{ log } 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);
+ expect(archiveService.resolveRecordStudentId).toHaveBeenCalledWith('enrollment', 1);
+ expect(studentsService.assertStudentAccess).toHaveBeenCalledWith(1, 7, false);
expect(archiveService.purgeEnrollment).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '学生档案', action: '永久删除报名记录', targetId: 1 }),
diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts
index 4cf083fc..11b5d8cc 100644
--- a/apps/server/src/archive/archive.service.ts
+++ b/apps/server/src/archive/archive.service.ts
@@ -57,6 +57,36 @@ export class ArchiveService {
return fullPath;
}
+ /**
+ * 解析某条档案子记录属于哪个学生(用于按学生范围做 IDOR 校验)。
+ * 找不到返回 null。
+ */
+ async resolveRecordStudentId(
+ kind: 'enrollment' | 'examScore' | 'learningRecord' | 'attachment',
+ id: number,
+ ): Promise {
+ 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) {
const student = await this.studentRepo.findOne({
where: { id: studentId },
diff --git a/apps/server/src/attendance-devices/dto/attendance-device.dto.ts b/apps/server/src/attendance-devices/dto/attendance-device.dto.ts
index e7731048..673aa6ed 100644
--- a/apps/server/src/attendance-devices/dto/attendance-device.dto.ts
+++ b/apps/server/src/attendance-devices/dto/attendance-device.dto.ts
@@ -26,6 +26,7 @@ export class CreateAttendanceDeviceDto {
@IsOptional()
@IsString()
+ @MaxLength(1000)
notes?: string;
}
@@ -57,5 +58,6 @@ export class UpdateAttendanceDeviceDto {
@IsOptional()
@IsString()
+ @MaxLength(1000)
notes?: string;
}
diff --git a/apps/server/src/attendance/attendance-import.controller.ts b/apps/server/src/attendance/attendance-import.controller.ts
index d8f1397e..892ff17b 100644
--- a/apps/server/src/attendance/attendance-import.controller.ts
+++ b/apps/server/src/attendance/attendance-import.controller.ts
@@ -37,6 +37,8 @@ export class AttendanceImportController extends AttendanceControllerBase {
@Body() dto: MatchDingRecordDto,
@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);
await logAudit(this.logService, req, {
module: '考勤管理', action: '匹配考勤记录', targetId: id, targetType: 'dingAttendanceRaw', detail: `匹配到学生 ${dto.studentId}`,
@@ -48,7 +50,10 @@ export class AttendanceImportController extends AttendanceControllerBase {
@Post('ding-attendance-raw/auto-match')
@RequirePermission('attendance:edit')
- async autoMatch() {
+ async autoMatch(@Request() req: { user: RequestUser }) {
+ if (!this.canManageAllAttendance(req)) {
+ throw new ForbiddenException('仅管理员可执行全局自动匹配');
+ }
return this.service.autoMatchDingRecords();
}
diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts
index d13b2ab7..2be7f208 100644
--- a/apps/server/src/attendance/attendance.service.ts
+++ b/apps/server/src/attendance/attendance.service.ts
@@ -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 { Repository, In, DataSource } from 'typeorm';
import {
@@ -251,6 +251,24 @@ export class AttendanceService {
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) {
return this.queries.getDingRaw(...args);
}
diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts
index 43b9469e..44bdf140 100644
--- a/apps/server/src/attendance/dto/attendance.dto.ts
+++ b/apps/server/src/attendance/dto/attendance.dto.ts
@@ -88,6 +88,7 @@ export class AttendanceRecordItem {
export class BatchCreateAttendanceDto {
@IsArray()
@ArrayNotEmpty()
+ @ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => AttendanceRecordItem)
records: AttendanceRecordItem[];
diff --git a/apps/server/src/auth/auth.module.ts b/apps/server/src/auth/auth.module.ts
index 420bfa83..1ace123e 100644
--- a/apps/server/src/auth/auth.module.ts
+++ b/apps/server/src/auth/auth.module.ts
@@ -7,6 +7,7 @@ import { User } from '../entities/user.entity';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
+import { getJwtSecret } from './jwt-secret';
import { RbacModule } from '../rbac/rbac.module';
@Module({
@@ -17,7 +18,7 @@ import { RbacModule } from '../rbac/rbac.module';
imports: [ConfigModule],
inject: [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') },
}),
}),
diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts
index d04ae1c1..8150db53 100644
--- a/apps/server/src/auth/auth.service.ts
+++ b/apps/server/src/auth/auth.service.ts
@@ -45,11 +45,11 @@ export class AuthService {
if (!valid) {
this.recordFailedAttempt(attemptKey);
const att = loginAttempts.get(attemptKey);
- const remaining = MAX_ATTEMPTS - (att?.count || 0);
- if (remaining > 0) {
- throw new UnauthorizedException(`用户名或密码错误,还剩 ${remaining} 次尝试机会`);
+ if (att?.lockedUntil && att.lockedUntil > new Date()) {
+ throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`);
}
- throw new UnauthorizedException(`登录失败次数过多,账号已被锁定 ${LOCK_MINUTES} 分钟`);
+ // 与“用户不存在”返回同一文案,避免用户名枚举
+ throw new UnauthorizedException('用户名或密码错误');
}
// 登录成功,清除失败计数
diff --git a/apps/server/src/auth/dto/auth.dto.ts b/apps/server/src/auth/dto/auth.dto.ts
index b32a24bf..3eaa22e4 100644
--- a/apps/server/src/auth/dto/auth.dto.ts
+++ b/apps/server/src/auth/dto/auth.dto.ts
@@ -1,10 +1,12 @@
-import { IsString, MinLength } from 'class-validator';
+import { IsString, MinLength, MaxLength } from 'class-validator';
export class LoginDto {
@IsString()
+ @MaxLength(64)
username: string;
@IsString()
- @MinLength(4)
+ @MinLength(8)
+ @MaxLength(72)
password: string;
}
diff --git a/apps/server/src/auth/jwt-secret.spec.ts b/apps/server/src/auth/jwt-secret.spec.ts
new file mode 100644
index 00000000..c325bd8a
--- /dev/null
+++ b/apps/server/src/auth/jwt-secret.spec.ts
@@ -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);
+ });
+});
diff --git a/apps/server/src/auth/jwt-secret.ts b/apps/server/src/auth/jwt-secret.ts
new file mode 100644
index 00000000..49e3795e
--- /dev/null
+++ b/apps/server/src/auth/jwt-secret.ts
@@ -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('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';
+}
diff --git a/apps/server/src/auth/strategies/jwt.strategy.ts b/apps/server/src/auth/strategies/jwt.strategy.ts
index 0e9ab64c..c1a004a1 100644
--- a/apps/server/src/auth/strategies/jwt.strategy.ts
+++ b/apps/server/src/auth/strategies/jwt.strategy.ts
@@ -6,6 +6,7 @@ import { Request } from 'express';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../entities/user.entity';
+import { getJwtSecret } from '../jwt-secret';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
},
]),
ignoreExpiration: false,
- secretOrKey: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'),
+ secretOrKey: getJwtSecret(config),
});
}
diff --git a/apps/server/src/bills/bills-export.service.ts b/apps/server/src/bills/bills-export.service.ts
index e3a38ba8..18af5fc9 100644
--- a/apps/server/src/bills/bills-export.service.ts
+++ b/apps/server/src/bills/bills-export.service.ts
@@ -114,7 +114,23 @@ export class BillsExportService {
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
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();
}
@@ -138,6 +154,19 @@ export class BillsExportService {
const doc = new PDFDocument({ size: 'A4', margin: 50 });
res.setHeader('Content-Type', 'application/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);
// 注册中文字体(优先使用系统字体,兼容 macOS 和 Linux)
diff --git a/apps/server/src/classes/classes-queries.service.ts b/apps/server/src/classes/classes-queries.service.ts
index 50c42caf..b6aa96d3 100644
--- a/apps/server/src/classes/classes-queries.service.ts
+++ b/apps/server/src/classes/classes-queries.service.ts
@@ -5,6 +5,7 @@ import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entitie
import { Classroom } from '../entities/classroom.entity';
import { syncDingTalkStudents } from '../integration/dingtalk-student-sync';
import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
+import { escapeLike } from '../common/like-escape';
import dayjs from '../common/dayjs';
interface AgentClassRow {
@@ -53,7 +54,7 @@ export class ClassesQueriesService {
}
qb.where('class.isArchived = :isArchived', { isArchived: false });
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 });
const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany();
return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) }));
diff --git a/apps/server/src/classes/classes.controller.spec.ts b/apps/server/src/classes/classes.controller.spec.ts
index 751dd4a2..388b6ead 100644
--- a/apps/server/src/classes/classes.controller.spec.ts
+++ b/apps/server/src/classes/classes.controller.spec.ts
@@ -127,9 +127,15 @@ describe('ClassesController purge route', () => {
it('writes permanent delete audit logs', async () => {
const service = {
purge: jest.fn().mockResolvedValue({ message: '已永久删除班级(不可恢复)' }),
+ assertClassAccess: 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: {} };
await controller.purge('1', req);
expect(service.purge).toHaveBeenCalledWith(1);
diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts
index 29dfaf7e..e27ac311 100644
--- a/apps/server/src/classes/classes.controller.ts
+++ b/apps/server/src/classes/classes.controller.ts
@@ -126,27 +126,35 @@ export class ClassesController {
/** 批量导入学生到班级(通过钉钉用户ID) */
@Post(':id/students/import')
@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);
}
/** 归档班级 */
@Put(':id/archive')
@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);
}
/** 取消归档 */
@Put(':id/restore')
@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);
}
@Put(':id')
@RequirePermission('class:edit')
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);
await logAudit(this.logService, req, {
module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto),
@@ -157,6 +165,7 @@ export class ClassesController {
@Delete(':id')
@RequirePermission('class:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
+ await this.assertReadAccess(req, +id);
const result = await this.service.remove(+id);
await logAudit(this.logService, req, {
module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class',
@@ -167,6 +176,7 @@ export class ClassesController {
@Delete(':id/permanent')
@RequirePermission('class:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
+ await this.assertReadAccess(req, +id);
const result = await this.service.purge(+id);
await logAudit(this.logService, req, {
module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复',
@@ -227,6 +237,7 @@ export class ClassesController {
@Post(':id/students')
@RequirePermission('class:edit')
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);
await logAudit(this.logService, req, {
module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`,
@@ -254,6 +265,7 @@ export class ClassesController {
@Param('studentId', ParseIntPipe) studentId: number,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertReadAccess(req, +id);
const result = await this.service.removeStudent(+id, +studentId);
await logAudit(this.logService, req, {
module: '班级管理', action: '移除学生', targetId: +id, targetType: 'class', detail: `移除学生${studentId}`,
@@ -271,6 +283,7 @@ export class ClassesController {
@Post(':id/teachers')
@RequirePermission('class:edit')
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);
await logAudit(this.logService, req, {
module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`,
@@ -295,6 +308,7 @@ export class ClassesController {
@Param('assignmentId', ParseIntPipe) assignmentId: number,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertReadAccess(req, +id);
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
await logAudit(this.logService, req, {
module: '班级管理', action: '移除教师角色', targetId: +id, targetType: 'class', detail: `移除教师分配${assignmentId}`,
@@ -309,6 +323,7 @@ export class ClassesController {
@Param('userId', ParseIntPipe) userId: number,
@Request() req: AuthenticatedRequest,
) {
+ await this.assertReadAccess(req, +id);
const result = await this.service.removeTeacher(+id, +userId);
await logAudit(this.logService, req, {
module: '班级管理', action: '移除教师', targetId: +id, targetType: 'class', detail: `移除教师${userId}`,
diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts
index 05084384..e6b5bd26 100644
--- a/apps/server/src/classes/classes.service.ts
+++ b/apps/server/src/classes/classes.service.ts
@@ -9,6 +9,7 @@ import { DataSource,
Repository,
In,
Like } from 'typeorm';
+import { escapeLike } from '../common/like-escape';
import {
Class,
ClassStudent,
@@ -102,7 +103,7 @@ export class ClassesService {
const where: Record = {};
if (query.status) where.status = query.status;
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
where.isArchived = query.isArchived ?? false;
diff --git a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts
index 4c8b6ba0..a5b1cf7c 100644
--- a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts
+++ b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts
@@ -185,7 +185,7 @@ export class ClassroomRentalsController {
@UploadedFile() file: Express.Multer.File,
@Request() req: AuthenticatedRequest,
) {
- if (!file) throw new BadRequestException('请上传合同文件');
+ if (!file) throw new BadRequestException('缺少上传文件');
const result = await this.service.attachContract(+id, file);
await logAudit(this.logService, req, {
module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental', detail: file.originalname,
@@ -203,6 +203,15 @@ export class ClassroomRentalsController {
`attachment; filename="${encodeURIComponent(originalName)}"`,
);
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);
}
diff --git a/apps/server/src/classroom-rentals/dto/rental.dto.ts b/apps/server/src/classroom-rentals/dto/rental.dto.ts
index 61f08c63..635d7664 100644
--- a/apps/server/src/classroom-rentals/dto/rental.dto.ts
+++ b/apps/server/src/classroom-rentals/dto/rental.dto.ts
@@ -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 {
@IsInt()
@@ -17,6 +50,7 @@ export class CreateRentalDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
+ @Validate(IsDateRangeValidConstraint)
endDate: string;
@IsOptional()
@@ -31,6 +65,7 @@ export class CreateRentalDto {
@IsOptional()
@IsString()
+ @MaxLength(1000)
notes?: string;
}
@@ -55,6 +90,7 @@ export class UpdateRentalDto {
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
+ @Validate(IsDateRangeValidConstraint)
endDate?: string;
@IsOptional()
@@ -69,5 +105,6 @@ export class UpdateRentalDto {
@IsOptional()
@IsString()
+ @MaxLength(1000)
notes?: string;
}
diff --git a/apps/server/src/common/like-escape.spec.ts b/apps/server/src/common/like-escape.spec.ts
new file mode 100644
index 00000000..3f7beaf4
--- /dev/null
+++ b/apps/server/src/common/like-escape.spec.ts
@@ -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('');
+ });
+});
diff --git a/apps/server/src/common/like-escape.ts b/apps/server/src/common/like-escape.ts
new file mode 100644
index 00000000..df0dd39f
--- /dev/null
+++ b/apps/server/src/common/like-escape.ts
@@ -0,0 +1,15 @@
+/**
+ * 转义 SQL LIKE 模式中的通配符,防止用户输入里的 `%` / `_` / `\` 扩大匹配范围。
+ *
+ * 反斜杠必须最先转义:否则用户输入的 `\` 会被数据库当作转义符,
+ * 把后面的通配符(或普通字符)变成字面量,改变匹配语义。
+ * 转义后配合 MySQL 默认的 `\` 转义符,`%` / `_` / `\` 都会被当作字面量匹配。
+ */
+export function escapeLike(input: string): string {
+ return input
+ .replace(/\\/g, '\\\\')
+ .replace(/%/g, '\\%')
+ .replace(/_/g, '\\_');
+}
+
+export default escapeLike;
diff --git a/apps/server/src/common/mime.spec.ts b/apps/server/src/common/mime.spec.ts
new file mode 100644
index 00000000..44e36992
--- /dev/null
+++ b/apps/server/src/common/mime.spec.ts
@@ -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);
+ });
+});
diff --git a/apps/server/src/common/mime.ts b/apps/server/src/common/mime.ts
new file mode 100644
index 00000000..26ec8333
--- /dev/null
+++ b/apps/server/src/common/mime.ts
@@ -0,0 +1,43 @@
+/**
+ * 上传附件 MIME 类型归一化。
+ *
+ * 存储/返回的 mimeType 不应直接信任客户端请求头(可被伪造),
+ * 统一按文件扩展名白名单归一化,白名单之外的按 octet-stream 处理。
+ */
+
+const MIME_BY_EXTENSION: Record = {
+ 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([
+ '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);
+}
diff --git a/apps/server/src/common/request-utils.ts b/apps/server/src/common/request-utils.ts
index 5ba570c4..813089c6 100644
--- a/apps/server/src/common/request-utils.ts
+++ b/apps/server/src/common/request-utils.ts
@@ -2,18 +2,25 @@
export interface RequestInfoSource {
headers?: Record;
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 } {
- const forwarded =
- req.headers?.['x-forwarded-for'] ||
- req.headers?.['x-real-ip'] ||
- req.connection?.remoteAddress ||
- '';
- const ipAddress = String(forwarded).split(',')[0].trim() || 'unknown';
+ const trustProxy = process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true';
+ let ipAddress: string;
+ if (trustProxy) {
+ const forwarded =
+ req.headers?.['x-forwarded-for'] || req.headers?.['x-real-ip'] || '';
+ 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);
return { ipAddress, userAgent };
}
diff --git a/apps/server/src/common/with-audit-log.ts b/apps/server/src/common/with-audit-log.ts
index 8362675d..b9a09901 100644
--- a/apps/server/src/common/with-audit-log.ts
+++ b/apps/server/src/common/with-audit-log.ts
@@ -33,13 +33,17 @@ export async function withAuditLog(
): Promise {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await operation();
- await logService.log({
- userId: req.user?.id,
- username: req.user?.username,
- ipAddress,
- userAgent,
- ...buildEntry(result),
- });
+ try {
+ await logService.log({
+ userId: req.user?.id,
+ username: req.user?.username,
+ ipAddress,
+ userAgent,
+ ...buildEntry(result),
+ });
+ } catch {
+ // 审计日志为 best-effort:写入失败绝不能把已成功的业务操作变成失败
+ }
return result;
}
@@ -53,11 +57,15 @@ export async function logAudit(
entry: AuditLogEntry,
): Promise {
const { ipAddress, userAgent } = extractRequestInfo(req);
- await logService.log({
- userId: req.user?.id,
- username: req.user?.username,
- ipAddress,
- userAgent,
- ...entry,
- });
+ try {
+ await logService.log({
+ userId: req.user?.id,
+ username: req.user?.username,
+ ipAddress,
+ userAgent,
+ ...entry,
+ });
+ } catch {
+ // 审计日志为 best-effort:日志失败绝不向外抛
+ }
}
diff --git a/apps/server/src/dashboard/dashboard.controller.ts b/apps/server/src/dashboard/dashboard.controller.ts
index 0ede9645..ffb004da 100644
--- a/apps/server/src/dashboard/dashboard.controller.ts
+++ b/apps/server/src/dashboard/dashboard.controller.ts
@@ -44,18 +44,18 @@ export class DashboardController {
}
@Get('gantt')
- getGanttData(@Query() query: DashboardGanttQueryDto) {
- return this.service.getGanttData(query);
+ async getGanttData(@Query() query: DashboardGanttQueryDto, @Request() req: { user: RequestUser }) {
+ return this.service.getGanttData(query, await this.getAccessibleClassIds(req));
}
@Get('expense-stats')
- getExpenseStats(@Query() query: DashboardPeriodQueryDto) {
- return this.service.getExpenseStats(query.periodStart, query.periodEnd);
+ async getExpenseStats(@Query() query: DashboardPeriodQueryDto, @Request() req: { user: RequestUser }) {
+ return this.service.getExpenseStats(query.periodStart, query.periodEnd, await this.getAccessibleClassIds(req));
}
@Get('room-ranking')
- getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto) {
- return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd);
+ async getRoomExpenseRanking(@Query() query: DashboardPeriodQueryDto, @Request() req: { user: RequestUser }) {
+ return this.service.getRoomExpenseRanking(query.periodStart, query.periodEnd, await this.getAccessibleClassIds(req));
}
@Get('class-attendance-ranking')
@@ -64,12 +64,12 @@ export class DashboardController {
}
@Get('classroom-occupancy')
- getClassroomOccupancy() {
- return this.service.getClassroomOccupancy();
+ async getClassroomOccupancy(@Request() req: { user: RequestUser }) {
+ return this.service.getClassroomOccupancy(await this.getAccessibleClassIds(req));
}
@Get('classroom-utilization')
- async getClassroomUtilization() {
- return this.service.getClassroomUtilizationStats();
+ async getClassroomUtilization(@Request() req: { user: RequestUser }) {
+ return this.service.getClassroomUtilizationStats(await this.getAccessibleClassIds(req));
}
}
diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts
index 974dd972..e3fbbd12 100644
--- a/apps/server/src/dashboard/dashboard.service.ts
+++ b/apps/server/src/dashboard/dashboard.service.ts
@@ -257,15 +257,22 @@ export class DashboardService {
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);
}
- 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);
}
- 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);
}
@@ -281,7 +288,9 @@ export class DashboardService {
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({
where: { status: 'available' as const },
order: { building: 'ASC', name: 'ASC' },
@@ -328,7 +337,11 @@ export class DashboardService {
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({
where: { status: 'available' as const },
});
diff --git a/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts b/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts
index ac7e6ea7..4b621565 100644
--- a/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts
+++ b/apps/server/src/dashboard/dto/dashboard-query.dto.spec.ts
@@ -4,7 +4,7 @@ import { validate } from 'class-validator';
import { DashboardGanttQueryDto, DashboardPeriodQueryDto } from './dashboard-query.dto';
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',
async (periodStart) => {
const dto = plainToInstance(DashboardPeriodQueryDto, { periodStart });
diff --git a/apps/server/src/dashboard/dto/dashboard-query.dto.ts b/apps/server/src/dashboard/dto/dashboard-query.dto.ts
index 5f2aafdb..2c3760b1 100644
--- a/apps/server/src/dashboard/dto/dashboard-query.dto.ts
+++ b/apps/server/src/dashboard/dto/dashboard-query.dto.ts
@@ -1,14 +1,12 @@
-import { IsISO8601, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
+import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class DashboardPeriodQueryDto {
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
periodStart?: string;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
periodEnd?: string;
}
diff --git a/apps/server/src/exams/exams.controller.spec.ts b/apps/server/src/exams/exams.controller.spec.ts
index 724195a8..1e237bf9 100644
--- a/apps/server/src/exams/exams.controller.spec.ts
+++ b/apps/server/src/exams/exams.controller.spec.ts
@@ -18,13 +18,23 @@ describe('ExamsController batch archive and restore', () => {
};
it.each(['batchArchive', 'batchRestore'] as const)(
- '%s uses the existing exam permission',
+ '%s requires the exam write permission',
(method) => {
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 () => {
const pipes = Reflect.getMetadata(PIPES_METADATA, ExamsController) as ValidationPipe[];
expect(pipes).toHaveLength(1);
diff --git a/apps/server/src/exams/exams.controller.ts b/apps/server/src/exams/exams.controller.ts
index cdcf0848..5e2dd1f8 100644
--- a/apps/server/src/exams/exams.controller.ts
+++ b/apps/server/src/exams/exams.controller.ts
@@ -1,4 +1,5 @@
import {
+ BadRequestException,
Body,
Controller,
Delete,
@@ -49,7 +50,7 @@ export class ExamsController {
}
@Put('batch-archive')
- @RequirePermission('exam:view')
+ @RequirePermission('exam:edit')
async batchArchive(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchArchive(
dto.ids,
@@ -63,7 +64,7 @@ export class ExamsController {
}
@Put('batch-restore')
- @RequirePermission('exam:view')
+ @RequirePermission('exam:edit')
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.batchRestore(
dto.ids,
@@ -83,7 +84,7 @@ export class ExamsController {
}
@Post()
- @RequirePermission('exam:view')
+ @RequirePermission('exam:create')
async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) {
const result = await this.service.create(dto, req.user.id, this.canManageAll(req));
await logAudit(this.logService, req, {
@@ -93,7 +94,7 @@ export class ExamsController {
}
@Put(':id/archive')
- @RequirePermission('exam:view')
+ @RequirePermission('exam:edit')
async archive(
@Param('id', ParseIntPipe) id: number,
@Request() req: AuthenticatedRequest,
@@ -106,7 +107,7 @@ export class ExamsController {
}
@Put(':id/restore')
- @RequirePermission('exam:view')
+ @RequirePermission('exam:edit')
async restore(
@Param('id', ParseIntPipe) id: number,
@Request() req: AuthenticatedRequest,
@@ -146,13 +147,17 @@ export class ExamsController {
}
@Put(':examId/scores/:scoreId')
- @RequirePermission('exam:view')
+ @RequirePermission('exam:edit')
async updateScore(
@Param('examId', ParseIntPipe) examId: number,
@Param('scoreId', ParseIntPipe) scoreId: number,
@Body() dto: UpdateExamScoreValueDto,
@Request() req: AuthenticatedRequest,
) {
+ // 防止空 PUT 静默清空成绩:score 必须显式提供(清空请传 null)
+ if (dto.score === undefined) {
+ throw new BadRequestException('score 字段必填(清空请传 null)');
+ }
const result = await this.service.updateScore(
examId,
scoreId,
diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts
index 7886b8c3..4b3cdee8 100644
--- a/apps/server/src/expenses/expenses.controller.ts
+++ b/apps/server/src/expenses/expenses.controller.ts
@@ -16,6 +16,7 @@ import {
ParseIntPipe,
UsePipes,
ValidationPipe,
+ BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import dayjs from '../common/dayjs';
@@ -351,8 +352,9 @@ export class ExpensesController {
@Post('utility/import')
@RequirePermission('expense:create')
- @UseInterceptors(FileInterceptor('file'))
+ @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
+ if (!file) throw new BadRequestException('缺少上传文件');
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));
const ws = workbook.worksheets[0];
@@ -416,8 +418,9 @@ export class ExpensesController {
@Post('personal/import')
@RequirePermission('expense:create')
- @UseInterceptors(FileInterceptor('file'))
+ @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
+ if (!file) throw new BadRequestException('缺少上传文件');
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));
const ws = workbook.worksheets[0];
diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts
index 6910942f..6685d5d0 100644
--- a/apps/server/src/expenses/expenses.service.ts
+++ b/apps/server/src/expenses/expenses.service.ts
@@ -14,6 +14,7 @@ import {
import { BillsService } from '../bills/bills.service';
import dayjs from '../common/dayjs';
import { ExpenseOperationsService } from './expense-operations.service';
+import { escapeLike } from '../common/like-escape';
/** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */
type RawScalarRow = Record;
@@ -147,7 +148,7 @@ export class ExpensesService {
}
roomQb.where('e.status = :status', { status: 'active' });
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) {
roomQb.andWhere('e.periodStart >= :periodStart', { periodStart: query.periodStart });
@@ -178,7 +179,7 @@ export class ExpensesService {
if (query?.keyword) {
personalQb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
- { keyword: `%${query.keyword}%` },
+ { keyword: `%${escapeLike(query.keyword)}%` },
);
}
if (query?.periodStart) {
diff --git a/apps/server/src/integration/config/integration-config.service.spec.ts b/apps/server/src/integration/config/integration-config.service.spec.ts
index cb559cbc..8710c58e 100644
--- a/apps/server/src/integration/config/integration-config.service.spec.ts
+++ b/apps/server/src/integration/config/integration-config.service.spec.ts
@@ -1,4 +1,5 @@
import { IntegrationConfigService } from './integration-config.service';
+import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto';
describe('IntegrationConfigService.testConnection', () => {
const originalFetch = global.fetch;
@@ -97,3 +98,96 @@ describe('IntegrationConfigService security boundaries', () => {
).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' },
+ },
+ ]);
+ });
+});
diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts
index ba12be88..941d5248 100644
--- a/apps/server/src/integration/config/integration-config.service.ts
+++ b/apps/server/src/integration/config/integration-config.service.ts
@@ -11,6 +11,7 @@ import {
IntegrationType,
SaveIntegrationConfigDto,
} from './dto/config.dto';
+import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto';
/** 第三方配置在 content JSON 中的存储结构。 */
interface StoredConfigShape {
@@ -34,7 +35,13 @@ export class IntegrationConfigService {
private parseStoredConfig(content: string): Record {
const parsed = JSON.parse(content) as StoredConfigShape;
const rawCfg = parsed.config || parsed;
- return rawCfg && typeof rawCfg === 'object' ? (rawCfg as Record) : {};
+ if (!rawCfg || typeof rawCfg !== 'object') return {};
+ const cfg = rawCfg as Record;
+ // 读取路径统一在这里解密 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 verified = !!token;
+ // 写库前对 appSecret 做静态加密(其他字段不动);已是信封则跳过避免二次加密。
+ if (finalConfig.appSecret && !isEncryptedSecret(stringify(finalConfig.appSecret))) {
+ finalConfig.appSecret = encryptSecret(stringify(finalConfig.appSecret));
+ }
+
const content = JSON.stringify({
type: request.type,
verify: verified,
diff --git a/apps/server/src/integration/config/secret-crypto.spec.ts b/apps/server/src/integration/config/secret-crypto.spec.ts
new file mode 100644
index 00000000..3127a01b
--- /dev/null
+++ b/apps/server/src/integration/config/secret-crypto.spec.ts
@@ -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);
+ });
+ });
+});
diff --git a/apps/server/src/integration/config/secret-crypto.ts b/apps/server/src/integration/config/secret-crypto.ts
new file mode 100644
index 00000000..80d66e95
--- /dev/null
+++ b/apps/server/src/integration/config/secret-crypto.ts
@@ -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;
+ 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":"","i":"","t":""}。
+ */
+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;
+ }
+}
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
index 63b96536..584126eb 100644
--- a/apps/server/src/main.ts
+++ b/apps/server/src/main.ts
@@ -1,4 +1,5 @@
import { NestFactory } from '@nestjs/core';
+import { ValidationPipe } from '@nestjs/common';
import helmet from 'helmet';
import compression from 'compression';
import { AppModule } from './app.module';
@@ -8,6 +9,9 @@ async function bootstrap() {
await runMigrationsOnStartup();
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.enableCors();
app.use(helmet());
diff --git a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts
index 2f1d255f..40889f01 100644
--- a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts
+++ b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts
@@ -73,7 +73,7 @@ describe('manual occupancy DTO bed requirements', () => {
});
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',
async (checkInDate) => {
const dto = Object.assign(new CheckInDto(), {
diff --git a/apps/server/src/occupancies/dto/occupancy.dto.ts b/apps/server/src/occupancies/dto/occupancy.dto.ts
index f2ffc227..d16b76bc 100644
--- a/apps/server/src/occupancies/dto/occupancy.dto.ts
+++ b/apps/server/src/occupancies/dto/occupancy.dto.ts
@@ -2,7 +2,6 @@ import {
IsArray,
IsBoolean,
IsInt,
- IsISO8601,
IsNumber,
IsOptional,
IsString,
@@ -18,12 +17,10 @@ export class CheckInDto {
roomId: number;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
checkInDate: string; // YYYY-MM-DD
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
billingStartDate?: string; // 默认=checkInDate,可调整
@IsOptional()
@@ -53,12 +50,10 @@ export class CheckInDto {
export class CheckOutDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
checkOutDate: string;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
billingEndDate?: string; // 默认=checkOutDate
@IsOptional()
@@ -71,12 +66,10 @@ export class TransferRoomDto {
newRoomId: number;
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
transferDate: string; // YYYY-MM-DD
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
@IsInt()
@@ -87,7 +80,6 @@ export class TransferRoomDto {
newLockerId?: number;
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日
@IsOptional()
@@ -100,12 +92,10 @@ export class BatchCheckOutDto {
ids: number[];
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
checkOutDate: string; // YYYY-MM-DD
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
billingEndDate?: string; // 默认=checkOutDate
@IsOptional()
diff --git a/apps/server/src/occupancies/occupancies.boundaries.spec.ts b/apps/server/src/occupancies/occupancies.boundaries.spec.ts
index 5298a1ca..5cf6817f 100644
--- a/apps/server/src/occupancies/occupancies.boundaries.spec.ts
+++ b/apps/server/src/occupancies/occupancies.boundaries.spec.ts
@@ -55,6 +55,7 @@ function createCheckInManager(options?: {
update: jest.fn(),
};
const queryResults = [
+ options?.student ?? { id: 3, organizationId: 7 },
options?.existingOccupancy ?? null,
options?.room ?? { id: 2, capacity: 4, status: 'available' },
...(options?.bed !== undefined ? [options.bed] : []),
diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts
index 16c9f4df..8400c0a7 100644
--- a/apps/server/src/occupancies/occupancies.controller.ts
+++ b/apps/server/src/occupancies/occupancies.controller.ts
@@ -261,7 +261,7 @@ export class OccupanciesController {
@Post('import')
@RequirePermission('occupancy:checkin')
- @UseInterceptors(FileInterceptor('file'))
+ @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async importCheckIn(
@UploadedFile() file: Express.Multer.File,
@Request() req: AuthenticatedRequest,
diff --git a/apps/server/src/rbac/dto/rbac.dto.ts b/apps/server/src/rbac/dto/rbac.dto.ts
index 52291065..4eda6514 100644
--- a/apps/server/src/rbac/dto/rbac.dto.ts
+++ b/apps/server/src/rbac/dto/rbac.dto.ts
@@ -1,97 +1,125 @@
import {
+ ArrayMaxSize,
ArrayUnique,
IsArray,
+ IsDateString,
IsInt,
+ IsNotEmpty,
IsOptional,
IsString,
+ Matches,
+ MaxLength,
Min,
MinLength,
+ registerDecorator,
+ ValidationOptions,
} 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 {
@IsString()
+ @IsNotEmpty()
+ @Matches(/^\S+$/)
+ @MaxLength(100)
name: string;
@IsOptional()
@IsString()
+ @MaxLength(1000)
description?: string;
@IsOptional()
@IsArray()
+ @ArrayMaxSize(500)
@ArrayUnique()
@IsInt({ each: true })
@Min(1, { each: true })
permissionIds?: number[];
}
-export class UpdateRoleDto {
- @IsOptional()
- @IsString()
- name?: string;
-
- @IsOptional()
- @IsString()
- description?: string;
-
- @IsOptional()
- @IsArray()
- @ArrayUnique()
- @IsInt({ each: true })
- @Min(1, { each: true })
- permissionIds?: number[];
-}
+export class UpdateRoleDto extends PartialType(CreateRoleDto) {}
export class CreateUserDto {
@IsString()
+ @IsNotEmpty()
+ @Matches(/^\S+$/)
+ @MaxLength(100)
username: string;
@IsString()
- @MinLength(4)
+ @MinLength(8)
+ @MaxLength(72)
+ @MaxByteLength(72)
+ @Matches(/^\S+$/)
password: string;
@IsString()
+ @IsNotEmpty()
+ @Matches(/^\S+(?: \S+)*$/)
+ @MaxLength(100)
name: string;
@IsOptional()
@IsArray()
+ @ArrayMaxSize(500)
@ArrayUnique()
@IsInt({ each: true })
@Min(1, { each: true })
roleIds?: number[];
}
-export class UpdateUserDto {
- @IsOptional()
- @IsString()
- username?: string;
-
- @IsOptional()
- @IsString()
- name?: string;
-
- @IsOptional()
- @IsArray()
- @ArrayUnique()
- @IsInt({ each: true })
- @Min(1, { each: true })
- roleIds?: number[];
-}
+// 更新账号沿用创建时的校验(username/name/roleIds),但密码只能走独立的重置密码端点
+// (ResetPasswordDto),因此这里从 CreateUserDto 排除 password 后再 PartialType。
+export class UpdateUserDto extends PartialType(
+ OmitType(CreateUserDto, ['password'] as const),
+) {}
export class ResetPasswordDto {
@IsString()
- @MinLength(4)
+ @MinLength(8)
+ @MaxLength(72)
+ @MaxByteLength(72)
+ @Matches(/^\S+$/)
password: string;
}
export class UpdateProfileDto {
@IsOptional()
+ @IsArray()
+ @ArrayMaxSize(100)
+ @ArrayUnique()
+ @IsString({ each: true })
+ @IsNotEmpty({ each: true })
+ @Matches(/\S/, { each: true })
subjects?: string[];
@IsOptional()
- @IsString()
+ @IsDateString()
joinedAt?: string;
@IsOptional()
@IsString()
+ @MaxLength(2000)
qualifications?: string;
}
diff --git a/apps/server/src/rbac/rbac-user.service.ts b/apps/server/src/rbac/rbac-user.service.ts
index 58317753..31072999 100644
--- a/apps/server/src/rbac/rbac-user.service.ts
+++ b/apps/server/src/rbac/rbac-user.service.ts
@@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import * as bcrypt from 'bcryptjs';
import { User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, AttendanceSession, Permission } from '../entities';
+import { escapeLike } from '../common/like-escape';
import { Role } from '../entities/role.entity';
@Injectable()
@@ -216,7 +217,7 @@ export class RbacUserService {
.andWhere('u.isArchived = :isArchived', { isArchived: false });
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();
@@ -264,6 +265,13 @@ export class RbacUserService {
const user = await this.userRepo.findOne({ where: { id } });
if (!user) throw new NotFoundException('用户不存在');
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,
+ };
}
}
diff --git a/apps/server/src/rbac/rbac.boundary.spec.ts b/apps/server/src/rbac/rbac.boundary.spec.ts
index 6e8067bd..25339bc0 100644
--- a/apps/server/src/rbac/rbac.boundary.spec.ts
+++ b/apps/server/src/rbac/rbac.boundary.spec.ts
@@ -1,6 +1,12 @@
import { ValidationPipe } from '@nestjs/common';
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?: {
permRepo?: Record;
@@ -87,7 +93,7 @@ describe('RBAC DTO id arrays', () => {
it.each([
[CreateRoleDto, { name: 'role', permissionIds: [1, '2'] }],
[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] }],
])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => {
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
@@ -99,3 +105,150 @@ describe('RBAC DTO id arrays', () => {
).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(
+ {},
+ );
+ });
+});
diff --git a/apps/server/src/rooms/room-query.service.ts b/apps/server/src/rooms/room-query.service.ts
index e5fddb21..6eeaa86d 100644
--- a/apps/server/src/rooms/room-query.service.ts
+++ b/apps/server/src/rooms/room-query.service.ts
@@ -7,6 +7,7 @@ import { Bed } from '../entities/bed.entity';
import { RoomInspectionsService } from './room-inspections.service';
import { occupancyWhereOnDate } from './room-occupancy-date';
import { parseRoomNumber } from './room-number';
+import { escapeLike } from '../common/like-escape';
import dayjs from '../common/dayjs';
/** getRawMany 原始行:驱动可能返回 string 或 number,故标量字段用联合类型 */
@@ -60,7 +61,7 @@ export class RoomQueryService {
if (query.building) qb.andWhere('room.building = :building', { building: query.building });
if (query.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 });
diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts
index 9a4b599c..16c8fc91 100644
--- a/apps/server/src/rooms/rooms.controller.ts
+++ b/apps/server/src/rooms/rooms.controller.ts
@@ -14,6 +14,7 @@ import {
UploadedFile,
UsePipes,
ValidationPipe,
+ BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
@@ -355,8 +356,9 @@ export class RoomsController {
@Post('import')
@RequirePermission('room:create')
- @UseInterceptors(FileInterceptor('file'))
+ @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
+ if (!file) throw new BadRequestException('缺少上传文件');
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));
diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts
index f2a69634..aa0f7c67 100644
--- a/apps/server/src/schedules/schedules.service.ts
+++ b/apps/server/src/schedules/schedules.service.ts
@@ -101,7 +101,8 @@ export class SchedulesService {
if (query.classroomId)
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId });
- else if (accessibleClassIds) {
+ // 无论是否传 classId,都强制应用当前用户可访问的班级范围(超管/管理员不受限)
+ if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
diff --git a/apps/server/src/students/students.agent.service.ts b/apps/server/src/students/students.agent.service.ts
index cf829ef7..da489bb7 100644
--- a/apps/server/src/students/students.agent.service.ts
+++ b/apps/server/src/students/students.agent.service.ts
@@ -4,6 +4,7 @@ import { Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { ClassStudent } from '../entities/class-student.entity';
import type { StudentAccessScope } from './student-access-scope';
+import { escapeLike } from '../common/like-escape';
/** getRawMany/getRawOne 原始行(select 别名即原始键名;可空列按 NULL 处理) */
interface AgentStudentRawRow {
@@ -86,7 +87,7 @@ export class StudentsAgentService {
if (query?.keyword) {
qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', {
- keyword: `%${query.keyword}%`,
+ keyword: `%${escapeLike(query.keyword)}%`,
});
}
if (query?.organizationId) {
diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts
index d84014ba..0ef06376 100644
--- a/apps/server/src/students/students.controller.ts
+++ b/apps/server/src/students/students.controller.ts
@@ -1,4 +1,5 @@
import {
+ BadRequestException,
Controller,
Get,
Post,
@@ -66,8 +67,12 @@ export class StudentsController {
@Get('basic-lookups')
@RequirePermission('student:basic-view', 'student:view')
- getBasicLookups() {
- return this.service.getBasicLookups();
+ async getBasicLookups(@Request() req: AuthenticatedRequest) {
+ const classIds = await this.service.getAccessibleClassIds(
+ req.user.id,
+ this.canManageAllStudents(req),
+ );
+ return this.service.getBasicLookups(classIds);
}
@Get('filter-lookups')
@@ -253,8 +258,9 @@ export class StudentsController {
@Post('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) {
+ if (!file?.buffer) throw new BadRequestException('缺少上传文件');
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));
const importData = parseStudentImportWorkbook(workbook);
diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts
index 0f8cfb9c..4c3bb3e7 100644
--- a/apps/server/src/students/students.service.ts
+++ b/apps/server/src/students/students.service.ts
@@ -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 { Like, Not, In, FindOptionsWhere, IsNull, Repository } from 'typeorm';
+import { escapeLike } from '../common/like-escape';
import { Student } from '../entities/student.entity';
import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
@@ -57,15 +58,7 @@ export class StudentsService {
private get imports(): StudentsImportService {
if (!this.importService) {
- this.importService = new StudentsImportService(
- this.repo,
- this.profileRepo,
- this.enrollmentRepo,
- this.examScoreRepo,
- this.learningRecordRepo,
- this.resultRepo,
- this.organizationRepo,
- );
+ this.importService = new StudentsImportService(this.repo);
}
return this.importService;
}
@@ -101,16 +94,43 @@ export class StudentsService {
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 {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
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({
select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'],
- where: { status: 'active' },
+ where: scopedStudentIds ? { status: 'active', id: In(scopedStudentIds) } : { status: 'active' },
order: { name: 'ASC' },
});
}
@@ -131,7 +151,7 @@ export class StudentsService {
accessibleClassIds?: number[],
) {
const where: FindOptionsWhere = {};
- 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?.status) {
where.status = query.status;
diff --git a/apps/server/src/sync/dto/schedule-sync.dto.spec.ts b/apps/server/src/sync/dto/schedule-sync.dto.spec.ts
index 8cb598fd..dd99bbea 100644
--- a/apps/server/src/sync/dto/schedule-sync.dto.spec.ts
+++ b/apps/server/src/sync/dto/schedule-sync.dto.spec.ts
@@ -19,7 +19,7 @@ describe('ScheduleSyncQueryDto', () => {
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',
async (dateFrom) => {
const dto = plainToInstance(ScheduleSyncQueryDto, { dateFrom });
diff --git a/apps/server/src/sync/dto/schedule-sync.dto.ts b/apps/server/src/sync/dto/schedule-sync.dto.ts
index 30c08f78..ac8694e3 100644
--- a/apps/server/src/sync/dto/schedule-sync.dto.ts
+++ b/apps/server/src/sync/dto/schedule-sync.dto.ts
@@ -1,10 +1,9 @@
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 {
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
- @IsISO8601({ strict: true })
dateFrom?: string;
@IsOptional()
diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts
index aa83bf37..130ca84e 100644
--- a/apps/server/src/sync/sync.controller.ts
+++ b/apps/server/src/sync/sync.controller.ts
@@ -200,7 +200,8 @@ export class SyncController {
@Query('platform') platform?: SyncPlatform,
@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);
}
// ── 排班同步 ──