fix(security): 认证/越权/注入/上传/凭据全链路加固
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - JWT 生产必填、密码 8-72 字节、防枚举;全局 ValidationPipe - classes/dashboard/schedules/students/attendance/archive/exams 越权与 IDOR 修复 - LIKE 通配符转义(14 处);上传 10MB 上限 + MIME 白名单 + 附件 XSS - 集成配置 appSecret AES 加密 + 回填脚本;审计 best-effort;IP 来源防伪造 Reviewed-by: OCR (open-codereview.ai)
This commit is contained in:
@@ -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",
|
||||
|
||||
67
apps/server/scripts/encrypt-integration-secrets.ts
Normal file
67
apps/server/scripts/encrypt-integration-secrets.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import datasource from '../datasource';
|
||||
import { encryptSecret, isEncryptedSecret } from '../src/integration/config/secret-crypto';
|
||||
import { IntegrationConfigDetail } from '../src/integration/entities/integration-config.entity';
|
||||
|
||||
/** integration_config_detail.content JSON 中与本次回填相关的结构。 */
|
||||
interface StoredConfigContent {
|
||||
type?: unknown;
|
||||
verify?: unknown;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// 主动检查加密密钥:缺失时 getEncryptionKey() 会静默使用开发回退密钥,
|
||||
// 绝不能用回退密钥加密生产数据,因此未配置时直接报错退出。
|
||||
if (!process.env.AI_CONFIG_ENCRYPTION_KEY) {
|
||||
throw new Error(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 未设置:为避免使用开发回退密钥加密数据,请先配置 AI_CONFIG_ENCRYPTION_KEY 再运行回填脚本',
|
||||
);
|
||||
}
|
||||
|
||||
await datasource.initialize();
|
||||
console.log('已连接数据库,开始回填第三方集成配置 appSecret 加密...');
|
||||
|
||||
try {
|
||||
const repo = datasource.getRepository(IntegrationConfigDetail);
|
||||
const rows = await repo.find();
|
||||
let processed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!row.content) continue;
|
||||
|
||||
let parsed: StoredConfigContent;
|
||||
try {
|
||||
parsed = JSON.parse(row.content) as StoredConfigContent;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const config = parsed.config;
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) continue;
|
||||
|
||||
const appSecret = config.appSecret;
|
||||
if (typeof appSecret !== 'string' || !appSecret || isEncryptedSecret(appSecret)) continue;
|
||||
|
||||
config.appSecret = encryptSecret(appSecret);
|
||||
row.content = JSON.stringify(parsed);
|
||||
await repo.save(row);
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
console.log(`回填完成:共处理 ${processed} 行(加密 appSecret)`);
|
||||
} finally {
|
||||
// 无论成功还是失败都关闭连接池,避免泄漏
|
||||
try {
|
||||
await datasource.destroy();
|
||||
} catch {
|
||||
// 销毁失败不影响主流程结果
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('回填失败:', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -57,6 +57,36 @@ export class ArchiveService {
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析某条档案子记录属于哪个学生(用于按学生范围做 IDOR 校验)。
|
||||
* 找不到返回 null。
|
||||
*/
|
||||
async resolveRecordStudentId(
|
||||
kind: 'enrollment' | 'examScore' | 'learningRecord' | 'attachment',
|
||||
id: number,
|
||||
): Promise<number | null> {
|
||||
switch (kind) {
|
||||
case 'enrollment': {
|
||||
const row = await this.enrollmentRepo.findOne({ where: { id }, select: ['studentId'] });
|
||||
return row?.studentId ?? null;
|
||||
}
|
||||
case 'examScore': {
|
||||
const row = await this.examScoreRepo.findOne({ where: { id }, select: ['studentId'] });
|
||||
return row?.studentId ?? null;
|
||||
}
|
||||
case 'learningRecord': {
|
||||
const row = await this.learningRecordRepo.findOne({ where: { id }, select: ['studentId'] });
|
||||
return row?.studentId ?? null;
|
||||
}
|
||||
case 'attachment': {
|
||||
const row = await this.attachmentRepo.findOne({ where: { id }, select: ['studentId'] });
|
||||
return row?.studentId ?? null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile(studentId: number) {
|
||||
const student = await this.studentRepo.findOne({
|
||||
where: { id: studentId },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AttendanceQueryService['getDingRaw']>) {
|
||||
return this.queries.getDingRaw(...args);
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ export class AttendanceRecordItem {
|
||||
export class BatchCreateAttendanceDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AttendanceRecordItem)
|
||||
records: AttendanceRecordItem[];
|
||||
|
||||
@@ -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') },
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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('用户名或密码错误');
|
||||
}
|
||||
|
||||
// 登录成功,清除失败计数
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
88
apps/server/src/auth/jwt-secret.spec.ts
Normal file
88
apps/server/src/auth/jwt-secret.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getJwtSecret } from './jwt-secret';
|
||||
|
||||
const FALLBACK = 'dev-only-insecure-jwt-secret-do-not-use-in-production';
|
||||
|
||||
function makeConfig(secret?: string): ConfigService {
|
||||
return {
|
||||
get: jest.fn((key: string) => (key === 'JWT_SECRET' ? secret : undefined)),
|
||||
} as unknown as ConfigService;
|
||||
}
|
||||
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
const originalSeedDev = process.env.SEED_DEV;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
}
|
||||
if (originalSeedDev === undefined) {
|
||||
delete process.env.SEED_DEV;
|
||||
} else {
|
||||
process.env.SEED_DEV = originalSeedDev;
|
||||
}
|
||||
});
|
||||
|
||||
describe('getJwtSecret', () => {
|
||||
it('returns JWT_SECRET when configured, regardless of NODE_ENV', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.SEED_DEV = 'false';
|
||||
expect(getJwtSecret(makeConfig('configured-secret'))).toBe('configured-secret');
|
||||
});
|
||||
|
||||
it('returns JWT_SECRET when configured and NODE_ENV is unset', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.SEED_DEV;
|
||||
expect(getJwtSecret(makeConfig('configured-secret'))).toBe('configured-secret');
|
||||
});
|
||||
|
||||
it('throws when NODE_ENV is unset and SEED_DEV is unset', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.SEED_DEV;
|
||||
expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/);
|
||||
});
|
||||
|
||||
it('throws when NODE_ENV=production', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
delete process.env.SEED_DEV;
|
||||
expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/);
|
||||
});
|
||||
|
||||
it('throws when NODE_ENV=production and SEED_DEV=true (SEED_DEV 只在 NODE_ENV 未设置时生效)', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.SEED_DEV = 'true';
|
||||
expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/);
|
||||
});
|
||||
|
||||
it('throws when NODE_ENV=staging', () => {
|
||||
process.env.NODE_ENV = 'staging';
|
||||
delete process.env.SEED_DEV;
|
||||
expect(() => getJwtSecret(makeConfig())).toThrow(/JWT_SECRET/);
|
||||
});
|
||||
|
||||
it('returns fallback when NODE_ENV=development', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
delete process.env.SEED_DEV;
|
||||
expect(getJwtSecret(makeConfig())).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('returns fallback when NODE_ENV=test', () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
delete process.env.SEED_DEV;
|
||||
expect(getJwtSecret(makeConfig())).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('returns fallback when SEED_DEV=true even if NODE_ENV is unset', () => {
|
||||
delete process.env.NODE_ENV;
|
||||
process.env.SEED_DEV = 'true';
|
||||
expect(getJwtSecret(makeConfig())).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it('returns fallback when SEED_DEV=true and NODE_ENV=development', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.SEED_DEV = 'true';
|
||||
expect(getJwtSecret(makeConfig())).toBe(FALLBACK);
|
||||
});
|
||||
});
|
||||
36
apps/server/src/auth/jwt-secret.ts
Normal file
36
apps/server/src/auth/jwt-secret.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
let warned = false;
|
||||
|
||||
/**
|
||||
* 获取 JWT 签名密钥。
|
||||
* - 已配置 JWT_SECRET 时直接返回该值(任何环境都正常返回)。
|
||||
* - 仅当 NODE_ENV 为 development/test,或 NODE_ENV 未设置且 npm run dev 注入的
|
||||
* SEED_DEV=true 时,允许回退到开发用默认密钥,并打印警告。
|
||||
* - NODE_ENV 为其他环境(含 production/staging)时,即使误设 SEED_DEV=true 也不回退;
|
||||
* 未配置 JWT_SECRET 直接抛错,禁止使用默认/公开密钥。
|
||||
*/
|
||||
export function getJwtSecret(config: ConfigService): string {
|
||||
const secret = config.get<string>('JWT_SECRET');
|
||||
if (secret) return secret;
|
||||
|
||||
const env = process.env.NODE_ENV;
|
||||
// SEED_DEV 是 npm run dev 的注入标记,只在 NODE_ENV 未设置(npm 脚本通常不设置)时生效;
|
||||
// production 等环境即使误设 SEED_DEV=true 也绝不能走回退密钥。
|
||||
const isDev =
|
||||
env === 'development' ||
|
||||
env === 'test' ||
|
||||
((env === undefined || env === '') && process.env.SEED_DEV === 'true');
|
||||
if (!isDev) {
|
||||
throw new Error(
|
||||
'JWT_SECRET 未配置:非 development/test 环境禁止使用默认密钥,请在 .env 中设置强随机 JWT_SECRET',
|
||||
);
|
||||
}
|
||||
if (!warned) {
|
||||
warned = true;
|
||||
console.warn(
|
||||
'[AuthModule] 警告:JWT_SECRET 未配置,开发环境使用回退密钥。生产环境必须配置!',
|
||||
);
|
||||
}
|
||||
return 'dev-only-insecure-jwt-secret-do-not-use-in-production';
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<AgentClassRow>();
|
||||
return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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<string, unknown> = {};
|
||||
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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
22
apps/server/src/common/like-escape.spec.ts
Normal file
22
apps/server/src/common/like-escape.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { escapeLike } from './like-escape';
|
||||
|
||||
describe('escapeLike', () => {
|
||||
it.each([
|
||||
['%', '\\%'],
|
||||
['_', '\\_'],
|
||||
['\\', '\\\\'],
|
||||
['50%_off', '50\\%\\_off'],
|
||||
['a\\b%c_d', 'a\\\\b\\%c\\_d'],
|
||||
])('escapes LIKE wildcard "%s" -> "%s"', (input, expected) => {
|
||||
expect(escapeLike(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('keeps ordinary strings unchanged', () => {
|
||||
expect(escapeLike('张三')).toBe('张三');
|
||||
expect(escapeLike('hello world 123')).toBe('hello world 123');
|
||||
});
|
||||
|
||||
it('keeps an empty string empty', () => {
|
||||
expect(escapeLike('')).toBe('');
|
||||
});
|
||||
});
|
||||
15
apps/server/src/common/like-escape.ts
Normal file
15
apps/server/src/common/like-escape.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 转义 SQL LIKE 模式中的通配符,防止用户输入里的 `%` / `_` / `\` 扩大匹配范围。
|
||||
*
|
||||
* 反斜杠必须最先转义:否则用户输入的 `\` 会被数据库当作转义符,
|
||||
* 把后面的通配符(或普通字符)变成字面量,改变匹配语义。
|
||||
* 转义后配合 MySQL 默认的 `\` 转义符,`%` / `_` / `\` 都会被当作字面量匹配。
|
||||
*/
|
||||
export function escapeLike(input: string): string {
|
||||
return input
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/%/g, '\\%')
|
||||
.replace(/_/g, '\\_');
|
||||
}
|
||||
|
||||
export default escapeLike;
|
||||
52
apps/server/src/common/mime.spec.ts
Normal file
52
apps/server/src/common/mime.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { normalizeMimeType, isInlineSafeMimeType } from './mime';
|
||||
|
||||
describe('normalizeMimeType', () => {
|
||||
it.each([
|
||||
['report.pdf', 'application/pdf'],
|
||||
['photo.png', 'image/png'],
|
||||
['pic.jpeg', 'image/jpeg'],
|
||||
['pic.jpg', 'image/jpeg'],
|
||||
['anim.webp', 'image/webp'],
|
||||
['doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
['sheet.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
['data.csv', 'text/csv'],
|
||||
['notes.txt', 'text/plain'],
|
||||
])('maps %s to %s by extension', (filename, expected) => {
|
||||
expect(normalizeMimeType(filename)).toBe(expected);
|
||||
});
|
||||
|
||||
it('is case-insensitive for the extension', () => {
|
||||
expect(normalizeMimeType('REPORT.PDF')).toBe('application/pdf');
|
||||
expect(normalizeMimeType('Photo.JPG')).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('ignores a spoofed client mimeType', () => {
|
||||
expect(normalizeMimeType('evil.png', 'text/html')).toBe('image/png');
|
||||
expect(normalizeMimeType('evil.html', 'image/png')).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('falls back to octet-stream for unknown/unsafe extensions', () => {
|
||||
expect(normalizeMimeType('script.svg')).toBe('application/octet-stream');
|
||||
expect(normalizeMimeType('payload.html')).toBe('application/octet-stream');
|
||||
expect(normalizeMimeType('virus.exe')).toBe('application/octet-stream');
|
||||
expect(normalizeMimeType('noextension')).toBe('application/octet-stream');
|
||||
expect(normalizeMimeType('')).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInlineSafeMimeType', () => {
|
||||
it('allows images and pdf', () => {
|
||||
expect(isInlineSafeMimeType('application/pdf')).toBe(true);
|
||||
expect(isInlineSafeMimeType('image/png')).toBe(true);
|
||||
expect(isInlineSafeMimeType('image/jpeg')).toBe(true);
|
||||
expect(isInlineSafeMimeType('image/webp')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects document, archive and scriptable types', () => {
|
||||
expect(isInlineSafeMimeType('application/octet-stream')).toBe(false);
|
||||
expect(isInlineSafeMimeType('text/html')).toBe(false);
|
||||
expect(isInlineSafeMimeType('image/svg+xml')).toBe(false);
|
||||
expect(isInlineSafeMimeType('text/plain')).toBe(false);
|
||||
expect(isInlineSafeMimeType('')).toBe(false);
|
||||
});
|
||||
});
|
||||
43
apps/server/src/common/mime.ts
Normal file
43
apps/server/src/common/mime.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 上传附件 MIME 类型归一化。
|
||||
*
|
||||
* 存储/返回的 mimeType 不应直接信任客户端请求头(可被伪造),
|
||||
* 统一按文件扩展名白名单归一化,白名单之外的按 octet-stream 处理。
|
||||
*/
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
csv: 'text/csv',
|
||||
txt: 'text/plain',
|
||||
};
|
||||
|
||||
const INLINE_SAFE_MIME_TYPES = new Set<string>([
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 按文件扩展名白名单归一化 MIME 类型。
|
||||
* 客户端提供的 mimeType 仅作参考,实际以扩展名为准。
|
||||
*/
|
||||
export function normalizeMimeType(filename: string, _clientMime?: string): string {
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
const ext = dotIndex >= 0 ? filename.slice(dotIndex + 1).toLowerCase() : '';
|
||||
return MIME_BY_EXTENSION[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* 该 MIME 类型是否允许 `Content-Disposition: inline` 内联展示
|
||||
* (仅限图片与 PDF 这类无脚本执行能力的类型,SVG 等一律视为不安全)。
|
||||
*/
|
||||
export function isInlineSafeMimeType(mime: string): boolean {
|
||||
return INLINE_SAFE_MIME_TYPES.has(mime);
|
||||
}
|
||||
@@ -2,18 +2,25 @@
|
||||
export interface RequestInfoSource {
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -33,13 +33,17 @@ export async function withAuditLog<T>(
|
||||
): Promise<T> {
|
||||
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<void> {
|
||||
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:日志失败绝不向外抛
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<string, string | number | Date | null>;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
const parsed = JSON.parse(content) as StoredConfigShape;
|
||||
const rawCfg = parsed.config || parsed;
|
||||
return rawCfg && typeof rawCfg === 'object' ? (rawCfg as Record<string, unknown>) : {};
|
||||
if (!rawCfg || typeof rawCfg !== 'object') return {};
|
||||
const cfg = rawCfg as Record<string, unknown>;
|
||||
// 读取路径统一在这里解密 appSecret(信封 → 明文),兼容存量明文。
|
||||
if (typeof cfg.appSecret === 'string' && isEncryptedSecret(cfg.appSecret)) {
|
||||
return { ...cfg, appSecret: decryptSecret(cfg.appSecret) };
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** 获取或创建主配置(全局单例) */
|
||||
@@ -109,6 +116,11 @@ export class IntegrationConfigService {
|
||||
const token = await this.getTokenForTest(request.type, finalConfig);
|
||||
const 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,
|
||||
|
||||
58
apps/server/src/integration/config/secret-crypto.spec.ts
Normal file
58
apps/server/src/integration/config/secret-crypto.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto';
|
||||
|
||||
describe('secret-crypto', () => {
|
||||
describe('encryptSecret / decryptSecret', () => {
|
||||
it('round-trips an appSecret through the JSON envelope', () => {
|
||||
const secret = 'ding-app-secret-abc123';
|
||||
const envelope = encryptSecret(secret);
|
||||
|
||||
expect(JSON.parse(envelope)).toEqual(
|
||||
expect.objectContaining({
|
||||
v: 1,
|
||||
c: expect.any(String),
|
||||
i: expect.any(String),
|
||||
t: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(envelope).not.toContain(secret);
|
||||
expect(decryptSecret(envelope)).toBe(secret);
|
||||
});
|
||||
|
||||
it('produces a different envelope per call (random IV)', () => {
|
||||
expect(encryptSecret('same-secret')).not.toBe(encryptSecret('same-secret'));
|
||||
});
|
||||
|
||||
it('returns plaintext unchanged when input is not an envelope (legacy compatibility)', () => {
|
||||
expect(decryptSecret('legacy-plain-secret')).toBe('legacy-plain-secret');
|
||||
expect(decryptSecret('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEncryptedSecret', () => {
|
||||
it('recognizes generated envelopes', () => {
|
||||
expect(isEncryptedSecret(encryptSecret('anything'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects plaintext, malformed JSON and partial envelopes', () => {
|
||||
expect(isEncryptedSecret('plain-secret')).toBe(false);
|
||||
expect(isEncryptedSecret('')).toBe(false);
|
||||
expect(isEncryptedSecret('not-json')).toBe(false);
|
||||
expect(isEncryptedSecret('{"v":1}')).toBe(false);
|
||||
expect(isEncryptedSecret('{"v":2,"c":"a","i":"b","t":"c"}')).toBe(false);
|
||||
expect(isEncryptedSecret('{"v":1,"c":"","i":"b","t":"c"}')).toBe(false);
|
||||
expect(isEncryptedSecret('{"v":1,"c":123,"i":"b","t":"c"}')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid envelope tolerance', () => {
|
||||
it('returns the input unchanged for structurally incomplete envelopes', () => {
|
||||
expect(decryptSecret('{"v":1}')).toBe('{"v":1}');
|
||||
expect(decryptSecret('{"v":1,"c":"a","i":"b"}')).toBe('{"v":1,"c":"a","i":"b"}');
|
||||
});
|
||||
|
||||
it('returns the input unchanged when decryption fails', () => {
|
||||
const garbage = '{"v":1,"c":"!!","i":"!!","t":"!!"}';
|
||||
expect(decryptSecret(garbage)).toBe(garbage);
|
||||
});
|
||||
});
|
||||
});
|
||||
60
apps/server/src/integration/config/secret-crypto.ts
Normal file
60
apps/server/src/integration/config/secret-crypto.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { decrypt, encrypt } from '../../ai-config/ai-config.helpers';
|
||||
|
||||
/**
|
||||
* appSecret 静态加密信封结构。
|
||||
* 复用 ai-config.helpers 的 AES-256-GCM 工具:encrypt 返回
|
||||
* { ciphertext, iv, authTag }(均 base64),此处组装为 JSON 字符串落库。
|
||||
*/
|
||||
interface SecretEnvelope {
|
||||
v: 1;
|
||||
c: string;
|
||||
i: string;
|
||||
t: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为 secret-crypto 生成的加密信封。
|
||||
* 仅当 JSON 可解析、v === 1 且 c/i/t 均为非空字符串时视为信封。
|
||||
*/
|
||||
export function isEncryptedSecret(value: string): boolean {
|
||||
if (typeof value !== 'string' || value.length === 0) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<SecretEnvelope>;
|
||||
return (
|
||||
parsed !== null &&
|
||||
typeof parsed === 'object' &&
|
||||
parsed.v === 1 &&
|
||||
typeof parsed.c === 'string' &&
|
||||
parsed.c.length > 0 &&
|
||||
typeof parsed.i === 'string' &&
|
||||
parsed.i.length > 0 &&
|
||||
typeof parsed.t === 'string' &&
|
||||
parsed.t.length > 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密 appSecret 并返回 JSON 信封字符串。
|
||||
* 形如 {"v":1,"c":"<ciphertext>","i":"<iv>","t":"<authTag>"}。
|
||||
*/
|
||||
export function encryptSecret(value: string): string {
|
||||
const { ciphertext, iv, authTag } = encrypt(value);
|
||||
return JSON.stringify({ v: 1, c: ciphertext, i: iv, t: authTag } satisfies SecretEnvelope);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密 appSecret。
|
||||
* 非信封(含存量明文)原样返回;信封解密失败时也原样返回,保证读取路径容错。
|
||||
*/
|
||||
export function decryptSecret(value: string): string {
|
||||
if (!isEncryptedSecret(value)) return value;
|
||||
try {
|
||||
const envelope = JSON.parse(value) as SecretEnvelope;
|
||||
return decrypt(envelope.c, envelope.i, envelope.t);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, jest.Mock>;
|
||||
@@ -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(
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<number[] | undefined> {
|
||||
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<Student> = {};
|
||||
if (query?.name) where.name = Like(`%${query.name}%`);
|
||||
if (query?.name) where.name = Like(`%${escapeLike(query.name)}%`);
|
||||
if (query?.organizationId) where.organizationId = Number(query.organizationId);
|
||||
if (query?.status) {
|
||||
where.status = query.status;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ── 排班同步 ──
|
||||
|
||||
Reference in New Issue
Block a user