diff --git a/apps/server/src/agent-context/get-business-context.tool.ts b/apps/server/src/agent-context/get-business-context.tool.ts index b138eac..ee751b9 100644 --- a/apps/server/src/agent-context/get-business-context.tool.ts +++ b/apps/server/src/agent-context/get-business-context.tool.ts @@ -39,10 +39,12 @@ export class GetBusinessContextTool implements ToolDef return { ok: true, value: { workflowKey: workflowKey.value } }; } - async execute(input: GetBusinessContextInput, context: AgentToolContext): Promise { - return this.service.getBusinessContext( - { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, - input.workflowKey, + execute(input: GetBusinessContextInput, context: AgentToolContext): Promise { + return Promise.resolve( + this.service.getBusinessContext( + { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, + input.workflowKey, + ), ); } } @@ -81,10 +83,12 @@ export class GetEntitySchemaTool implements ToolDef<{ entityKey: string }> { return { ok: true, value: { entityKey } }; } - async execute(input: { entityKey: string }, context: AgentToolContext): Promise { - return this.service.getEntitySchema( - { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, - input.entityKey, + execute(input: { entityKey: string }, context: AgentToolContext): Promise { + return Promise.resolve( + this.service.getEntitySchema( + { permissions: context.permissions, isSuperAdmin: context.isSuperAdmin }, + input.entityKey, + ), ); } } diff --git a/apps/server/src/agent-context/get-pending-tasks.tool.ts b/apps/server/src/agent-context/get-pending-tasks.tool.ts index 20fb37a..7c6d48d 100644 --- a/apps/server/src/agent-context/get-pending-tasks.tool.ts +++ b/apps/server/src/agent-context/get-pending-tasks.tool.ts @@ -39,7 +39,7 @@ export class GetPendingTasksTool implements ToolDef { if (input.workflowKey === undefined) return { ok: true, value: {} }; if ( typeof input.workflowKey !== 'string' || - !(BUSINESS_WORKFLOW_KEYS as readonly string[]).includes(input.workflowKey) + !(BUSINESS_WORKFLOW_KEYS).includes(input.workflowKey) ) { return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` }; } diff --git a/apps/server/src/agent-context/pending-tasks.service.ts b/apps/server/src/agent-context/pending-tasks.service.ts index 4f310f8..17a78ad 100644 --- a/apps/server/src/agent-context/pending-tasks.service.ts +++ b/apps/server/src/agent-context/pending-tasks.service.ts @@ -166,8 +166,8 @@ export class PendingTasksService { continue; } const { sql, params } = definition.sql(scope); - const rows = await this.dataSource.query(sql, params); - const count = Number((rows as Array>)[0]?.cnt ?? 0); + const rows = await this.dataSource.query>>(sql, params); + const count = Number(rows[0]?.cnt ?? 0); tasks.push({ key: definition.key, label: definition.label, diff --git a/apps/server/src/agent-tools/tools/create-student.tool.ts b/apps/server/src/agent-tools/tools/create-student.tool.ts index ddf79f1..6b48716 100644 --- a/apps/server/src/agent-tools/tools/create-student.tool.ts +++ b/apps/server/src/agent-tools/tools/create-student.tool.ts @@ -28,6 +28,11 @@ const FORBIDDEN_INPUT_KEYS = new Set([ const PHONE_RE = /^1[3-9]\d{9}$/; +/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */ +function stringify(value: unknown): string { + return String(value); +} + /** * Creates a student archive from form-confirmed data. * @@ -99,7 +104,7 @@ export class CreateStudentTool implements ToolDef { } if (input.gender !== undefined) { - if (!['male', 'female', '男', '女'].includes(String(input.gender))) { + if (!['male', 'female', '男', '女'].includes(stringify(input.gender))) { return { ok: false, error: 'gender 只能是 male/female/男/女' }; } result.gender = input.gender as CreateStudentInput['gender']; diff --git a/apps/server/src/agent-tools/tools/update-students.tool.ts b/apps/server/src/agent-tools/tools/update-students.tool.ts index 8e2a5af..71ede41 100644 --- a/apps/server/src/agent-tools/tools/update-students.tool.ts +++ b/apps/server/src/agent-tools/tools/update-students.tool.ts @@ -151,8 +151,9 @@ export class UpdateStudentsTool implements ToolDef { const seenIds = new Set(); const updates: UpdateStudentInput[] = []; - for (let index = 0; index < input.updates.length; index += 1) { - const raw = input.updates[index]; + const updatesList = input.updates as unknown[]; + for (let index = 0; index < updatesList.length; index += 1) { + const raw = updatesList[index]; if (!isPlainRecord(raw)) { return { ok: false, error: `第 ${index + 1} 条更新格式无效` }; } diff --git a/apps/server/src/ai-chat/ai-chat.service.spec.ts b/apps/server/src/ai-chat/ai-chat.service.spec.ts index 50a5f94..5312c74 100644 --- a/apps/server/src/ai-chat/ai-chat.service.spec.ts +++ b/apps/server/src/ai-chat/ai-chat.service.spec.ts @@ -1,5 +1,4 @@ import { - BadRequestException, ConflictException, ForbiddenException, NotFoundException, diff --git a/apps/server/src/ai-chat/ai-chat.service.ts b/apps/server/src/ai-chat/ai-chat.service.ts index ec390e1..9e98c33 100644 --- a/apps/server/src/ai-chat/ai-chat.service.ts +++ b/apps/server/src/ai-chat/ai-chat.service.ts @@ -17,7 +17,7 @@ import { AiReviewService } from './ai-review.service'; import { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { AiConversation, AiMessage, AiToolRun } from './entities'; +import { AiAttachment, AiConversation, AiMessage, AiToolRun } from './entities'; import type { GenerationInput, ModelContentPart, @@ -157,7 +157,7 @@ export class AiChatService extends AiChatServiceBase { buildUserContent( text: string, - attachments: any[], + attachments: AiAttachment[], supportsVision: boolean, ): Promise { return buildUserContent(this, text, attachments, supportsVision); diff --git a/apps/server/src/ai-chat/ai-chat.streaming.ts b/apps/server/src/ai-chat/ai-chat.streaming.ts index ef01afe..331e8bb 100644 --- a/apps/server/src/ai-chat/ai-chat.streaming.ts +++ b/apps/server/src/ai-chat/ai-chat.streaming.ts @@ -15,7 +15,7 @@ import { MAX_HISTORY_MESSAGES, SYSTEM_PROMPT, } from './ai-chat.types'; -import { AiMessage } from './entities'; +import { AiAttachment, AiMessage } from './entities'; import type { AuthenticatedUser } from '../authorization'; import { a2uiReviewSubmitInfo, @@ -315,7 +315,7 @@ export async function buildContext( export async function buildUserContent( context: AiChatServiceContext, text: string, - attachments: any[], + attachments: AiAttachment[], supportsVision: boolean, ): Promise { if (!attachments.length) return text; diff --git a/apps/server/src/ai-chat/ai-chat.submissions.ts b/apps/server/src/ai-chat/ai-chat.submissions.ts index ea8a9ad..bdae717 100644 --- a/apps/server/src/ai-chat/ai-chat.submissions.ts +++ b/apps/server/src/ai-chat/ai-chat.submissions.ts @@ -1,8 +1,4 @@ -import type { - AiChatServiceContext, - AiSseEmitter, -} from './ai-chat.types'; -import type { AuthenticatedUser } from '../authorization'; +import type { AiChatServiceContext } from './ai-chat.types'; export async function resolveFormConversationId( context: AiChatServiceContext, diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts index d5e07b1..e865a26 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.import.ts @@ -1,7 +1,7 @@ import { IMPORT_STEP_KEYS, + type ImportRunDetail, type ImportStageRequest, - type ImportStepKey, } from '../imports/imports.types'; import { permittedStepKeys } from '../imports/imports.access'; import { expandStageSheets } from '../imports/imports.mapping'; @@ -111,7 +111,7 @@ export const executeStartImportWizard = makeImportToolExecutor( async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => { const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs); const [attachment] = await context.attachmentService.requireReadyOwned(ac.userId, [ - attachmentId as number, + attachmentId, ]); if (!isExcelAttachment(attachment)) throw new Error('附件不是 Excel 文件,无法生成导入向导'); const stages = Array.isArray(parsedRecord.stages) @@ -195,7 +195,7 @@ export const executeStartImportWizard = makeImportToolExecutor( }); }); -export function compactImportWizard(detail: any): { +export function compactImportWizard(detail: ImportRunDetail): { runId: string; fileName: string; sheets: Array<{ @@ -209,13 +209,13 @@ export function compactImportWizard(detail: any): { return { runId: detail.id, fileName: detail.fileName, - sheets: detail.sheets.map((sheet: any) => ({ + sheets: detail.sheets.map((sheet) => ({ name: sheet.name, suggestedStepKey: sheet.suggestedStepKey, headers: sheet.headers, rowCount: sheet.rowCount, })), - steps: detail.steps.map((step: any) => ({ + steps: detail.steps.map((step) => ({ stepKey: step.stepKey, label: step.label, sheets: step.sheets, diff --git a/apps/server/src/ai-chat/ai-chat.tool-actions.ts b/apps/server/src/ai-chat/ai-chat.tool-actions.ts index aea7f18..390a140 100644 --- a/apps/server/src/ai-chat/ai-chat.tool-actions.ts +++ b/apps/server/src/ai-chat/ai-chat.tool-actions.ts @@ -2,6 +2,12 @@ import { AiReview } from './entities/ai-review.entity'; import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types'; import { buildA2uiArtifact } from './ai-a2ui.artifact'; import { finishToolRun, startToolRun } from './ai-chat.tools'; + +/** Array.isArray 的类型守卫:把 unknown 收窄为 unknown[] 而非 any[]。 */ +function isUnknownArray(value: unknown): value is unknown[] { + return Array.isArray(value); +} + export async function executeRenderForm( context: AiChatServiceContext, messageId: number, @@ -229,7 +235,7 @@ export async function executeRenderChart( if (!assistant) throw new Error('assistant message missing'); const chart = context.chartService.createChart(parsedArgs); const existingCharts = assistant.metadata?.a2uiChart; - const charts = Array.isArray(existingCharts) + const charts = isUnknownArray(existingCharts) ? [...existingCharts] : existingCharts ? [existingCharts] diff --git a/apps/server/src/ai-chat/ai-chat.types.ts b/apps/server/src/ai-chat/ai-chat.types.ts index fff9ed6..bb6f598 100644 --- a/apps/server/src/ai-chat/ai-chat.types.ts +++ b/apps/server/src/ai-chat/ai-chat.types.ts @@ -14,6 +14,7 @@ import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service'; import { AiModelStreamService } from './ai-model-stream.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { + AiAttachment, AiConversation, AiMessage, AiReview, @@ -150,7 +151,7 @@ export interface AiChatServiceContext { ): Promise; buildUserContent( text: string, - attachments: any[], + attachments: AiAttachment[], supportsVision: boolean, ): Promise; truncateText(value: string, max: number): string; diff --git a/apps/server/src/attendance-devices/attendance-devices.controller.ts b/apps/server/src/attendance-devices/attendance-devices.controller.ts index 6177e8c..142e234 100644 --- a/apps/server/src/attendance-devices/attendance-devices.controller.ts +++ b/apps/server/src/attendance-devices/attendance-devices.controller.ts @@ -6,6 +6,13 @@ import { extractRequestInfo } from '../common/request-utils'; import { AttendanceDevicesService } from './attendance-devices.service'; import { CreateAttendanceDeviceDto, UpdateAttendanceDeviceDto } from './dto/attendance-device.dto'; import { AttendanceDeviceStatus } from '../entities'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + headers?: Record; + connection?: { remoteAddress?: string }; +} @UseGuards(JwtAuthGuard) @Controller('attendance-devices') @@ -29,7 +36,7 @@ export class AttendanceDevicesController { @Post() @RequirePermission('classroom:edit') - async create(@Body() dto: CreateAttendanceDeviceDto, @Request() req: any) { + async create(@Body() dto: CreateAttendanceDeviceDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); await this.logService.log({ @@ -48,7 +55,7 @@ export class AttendanceDevicesController { @Put(':id') @RequirePermission('classroom:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceDeviceDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceDeviceDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(id, dto); await this.logService.log({ @@ -67,7 +74,7 @@ export class AttendanceDevicesController { @Delete(':id') @RequirePermission('classroom:edit') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(id); await this.logService.log({ diff --git a/apps/server/src/attendance/attendance-import.controller.ts b/apps/server/src/attendance/attendance-import.controller.ts index e3a93fa..d8f1397 100644 --- a/apps/server/src/attendance/attendance-import.controller.ts +++ b/apps/server/src/attendance/attendance-import.controller.ts @@ -6,7 +6,7 @@ import { AttendanceImportService } from './attendance-import.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AuthorizationService } from '../authorization'; import { logAudit } from '../common/with-audit-log'; -import { extractRequestInfo } from '../common/request-utils'; +import { extractRequestInfo, type RequestInfoSource } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { DingTalkImportDto } from './dto/dingtalk-import.dto'; import { QueryDingRawDto, MatchDingRecordDto } from './dto/attendance.dto'; @@ -35,7 +35,7 @@ export class AttendanceImportController extends AttendanceControllerBase { async matchDingRecord( @Param('id', ParseIntPipe) id: number, @Body() dto: MatchDingRecordDto, - @Request() req: any, + @Request() req: { user: RequestUser }, ) { const result = await this.service.matchDingRecord(id, dto); await logAudit(this.logService, req, { @@ -69,7 +69,10 @@ export class AttendanceImportController extends AttendanceControllerBase { */ @Post('attendance-records/import/dingtalk') @RequirePermission('attendance:create') - async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) { + async importFromDingTalk( + @Body() dto: DingTalkImportDto, + @Request() req: { user: RequestUser } & RequestInfoSource, + ) { const { ipAddress, userAgent } = extractRequestInfo(req); const canManageAll = this.canManageAllAttendance(req); let userIds: string[]; diff --git a/apps/server/src/attendance/attendance-records.controller.ts b/apps/server/src/attendance/attendance-records.controller.ts index fd97615..c8c9d6b 100644 --- a/apps/server/src/attendance/attendance-records.controller.ts +++ b/apps/server/src/attendance/attendance-records.controller.ts @@ -6,7 +6,7 @@ import { AttendanceImportService } from './attendance-import.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { AuthorizationService } from '../authorization'; import { logAudit } from '../common/with-audit-log'; -import { extractRequestInfo } from '../common/request-utils'; +import { extractRequestInfo, type RequestInfoSource } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchCreateAttendanceDto, @@ -114,7 +114,10 @@ export class AttendanceRecordsController extends AttendanceControllerBase { // ── Batch create attendance records ── @Post('attendance-records/batch') @RequirePermission('attendance:create') - async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) { + async batchCreate( + @Body() dto: BatchCreateAttendanceDto, + @Request() req: { user: RequestUser } & RequestInfoSource, + ) { const { ipAddress, userAgent } = extractRequestInfo(req); const canManageAll = this.canManageAllAttendance(req); if (!canManageAll && dto.records.some((record) => record.classId == null)) { @@ -144,7 +147,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { // ── Generate attendance records from schedules (with optional date range) ── @Post('attendance-records/generate-from-schedules') @RequirePermission('attendance:create') - async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) { + async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: { user: RequestUser }) { await this.assertClassAccess(req, dto.classId); const result = await this.service.generateFromSchedules(dto); await logAudit(this.logService, req, { @@ -239,7 +242,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { @RequirePermission('attendance:edit', 'attendance:self-edit') async batchUpdateStatus( @Body() dto: BatchUpdateAttendanceStatusDto, - @Request() req: any, + @Request() req: { user: RequestUser }, ) { const failedIds: number[] = []; let updated = 0; @@ -269,7 +272,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { async update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceRecordDto, - @Request() req: any, + @Request() req: { user: RequestUser }, ) { const existing = await this.service.findAttendanceRecord(id); if (existing.classId == null && !this.canManageAllAttendance(req)) { @@ -286,7 +289,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { // ── Delete a single attendance record ── @Delete('attendance-records/:id') @RequirePermission('attendance:edit', 'attendance:self-edit') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: { user: RequestUser }) { const existing = await this.service.findAttendanceRecord(id); if (existing.classId == null && !this.canManageAllAttendance(req)) { throw new ForbiddenException('无权删除未关联班级的考勤记录'); @@ -334,7 +337,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase { async exportReport( @Query() query: AttendanceReportQueryDto, @Res() res: Response, - @Request() req: any, + @Request() req: { user: RequestUser }, ) { if (query.classId) await this.assertClassAccess(req, query.classId); const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req)); diff --git a/apps/server/src/auth/auth.controller.ts b/apps/server/src/auth/auth.controller.ts index c07dbaf..b992c1c 100644 --- a/apps/server/src/auth/auth.controller.ts +++ b/apps/server/src/auth/auth.controller.ts @@ -2,10 +2,15 @@ import { Controller, Post, Body, Get, Request, Req } from '@nestjs/common'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/auth.dto'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { extractRequestInfo, type RequestInfoSource } from '../common/request-utils'; import { Throttle } from '@nestjs/throttler'; import { Public } from './decorators/public.decorator'; import { Authenticated } from './decorators/authenticated.decorator'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest extends RequestInfoSource { + user: AuthenticatedUser; +} @Controller('auth') export class AuthController { @@ -17,7 +22,7 @@ export class AuthController { @Public() @Post('login') @Throttle({ default: { ttl: 60000, limit: 5 } }) - async login(@Body() dto: LoginDto, @Req() req: any) { + async login(@Body() dto: LoginDto, @Req() req: RequestInfoSource) { const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.authService.login(dto, ipAddress); @@ -31,12 +36,12 @@ export class AuthController { status: 'success', }); return result; - } catch (e: any) { + } catch (e: unknown) { await this.logService.log({ username: dto.username, module: '认证', action: '登录失败', - detail: e.message || '密码错误', + detail: e instanceof Error ? e.message || '密码错误' : '密码错误', ipAddress, userAgent, status: 'fail', @@ -47,7 +52,7 @@ export class AuthController { @Authenticated() @Get('profile') - getProfile(@Request() req: any) { + getProfile(@Request() req: AuthenticatedRequest) { return req.user; } } diff --git a/apps/server/src/auth/auth.service.ts b/apps/server/src/auth/auth.service.ts index b3dd0a0..d04ae1c 100644 --- a/apps/server/src/auth/auth.service.ts +++ b/apps/server/src/auth/auth.service.ts @@ -93,7 +93,7 @@ export class AuthService { loginAttempts.set(key, attempt); } - async validateUser(payload: any) { + async validateUser(payload: { sub?: number }) { return this.userRepo.findOne({ where: { id: payload.sub } }); } } diff --git a/apps/server/src/bills/bills-export.service.ts b/apps/server/src/bills/bills-export.service.ts index 3f94ac3..e3a38ba 100644 --- a/apps/server/src/bills/bills-export.service.ts +++ b/apps/server/src/bills/bills-export.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call -- pdfkit/exceljs 无完整 TS 类型,属第三方库边界 */ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; diff --git a/apps/server/src/bills/bills.controller.ts b/apps/server/src/bills/bills.controller.ts index 43085e1..746cdc7 100644 --- a/apps/server/src/bills/bills.controller.ts +++ b/apps/server/src/bills/bills.controller.ts @@ -27,6 +27,13 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import type { Response } from 'express'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} @UseGuards(JwtAuthGuard) @Controller('bills') @@ -42,7 +49,7 @@ export class BillsController { @Post('generate') @RequirePermission('bill:generate') - async generateBills(@Body() dto: GenerateBillsDto, @Request() req: any) { + async generateBills(@Body() dto: GenerateBillsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.generateBills(dto); await logAudit(this.logService, req, { module: '账单管理', action: '生成账单', detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`, @@ -91,7 +98,7 @@ export class BillsController { async updateStatus( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBillStatusDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.updateStatus(id, dto); await logAudit(this.logService, req, { @@ -114,7 +121,7 @@ export class BillsController { @Put('batch/status') @RequirePermission('bill:confirm') - async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) { + async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchUpdateStatus(body.ids, body.status); await logAudit(this.logService, req, { module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`, @@ -139,7 +146,7 @@ export class BillsController { @Post(':id/cancel') @RequirePermission('bill:delete') - async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) { + async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) { const result = await this.service.cancel(id, dto, req.user?.id); await logAudit(this.logService, req, { module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason, @@ -149,7 +156,7 @@ export class BillsController { @Delete(':id') @RequirePermission('bill:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(id); await logAudit(this.logService, req, { module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill', @@ -159,7 +166,7 @@ export class BillsController { @Delete(':id/permanent') @RequirePermission('bill:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(id); await logAudit(this.logService, req, { module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复', @@ -169,7 +176,7 @@ export class BillsController { @Post('batch-permanent-delete') @RequirePermission('bill:purge') - async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurge(body.ids || []); await logAudit(this.logService, req, { module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -179,7 +186,7 @@ export class BillsController { @Post('batch/delete') @RequirePermission('bill:delete') - async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { + async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRemove(body.ids); await logAudit(this.logService, req, { module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`, @@ -190,12 +197,12 @@ export class BillsController { @Get('export/excel') @RequirePermission('bill:export-excel') async exportExcel( + @Req() req: AuthenticatedRequest, @Query('periodStart') periodStart?: string, @Query('periodEnd') periodEnd?: string, @Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number, @Query('status') status?: string, @Res() res?: Response, - @Req() req?: any, ) { await logAudit(this.logService, req, { module: '账单管理', action: '导出账单', detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`, @@ -213,7 +220,7 @@ export class BillsController { @Get('export/pdf/:id') @RequirePermission('bill:export-pdf') - async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) { + async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: AuthenticatedRequest) { await logAudit(this.logService, req, { module: '账单管理', action: '导出账单', targetId: id, targetType: 'bill', }); diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index 595be78..feab198 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -176,8 +176,10 @@ export class BillsService { } /** 查询时附加钱包余额和实际支付数据。 */ - private async attachDepositInfo(bills: Bill[]): Promise { - if (!bills?.length) return bills; + private async attachDepositInfo( + bills: Bill[], + ): Promise> { + if (!bills?.length) return bills as Array; const studentIds = Array.from(new Set(bills.map((bill) => bill.studentId))); const wallets = await this.dataSource .getRepository(StudentWallet) @@ -185,7 +187,7 @@ export class BillsService { .where('wallet.studentId IN (:...ids)', { ids: studentIds }) .getMany(); const balanceMap = new Map( - wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]), + wallets.map((wallet) => [wallet.studentId, Number(wallet.balance || 0)]), ); return bills.map((bill) => ({ ...bill, diff --git a/apps/server/src/classes/classes.controller.ts b/apps/server/src/classes/classes.controller.ts index 139cbb5..29dfaf7 100644 --- a/apps/server/src/classes/classes.controller.ts +++ b/apps/server/src/classes/classes.controller.ts @@ -115,7 +115,7 @@ export class ClassesController { @Post() @RequirePermission('class:create') - async create(@Body() dto: CreateClassDto, @Request() req: any) { + async create(@Body() dto: CreateClassDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto); await logAudit(this.logService, req, { module: '班级管理', action: '创建班级', targetId: result.id, targetType: 'class', detail: `班级${result.code} ${result.name}`, @@ -146,7 +146,7 @@ export class ClassesController { @Put(':id') @RequirePermission('class:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: AuthenticatedRequest) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto), @@ -156,7 +156,7 @@ export class ClassesController { @Delete(':id') @RequirePermission('class:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class', @@ -166,7 +166,7 @@ export class ClassesController { @Delete(':id/permanent') @RequirePermission('class:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复', @@ -226,7 +226,7 @@ export class ClassesController { @Post(':id/students') @RequirePermission('class:edit') - async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: any) { + async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.addStudents(+id, dto.studentIds); await logAudit(this.logService, req, { module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`, @@ -252,7 +252,7 @@ export class ClassesController { async removeStudent( @Param('id', ParseIntPipe) id: number, @Param('studentId', ParseIntPipe) studentId: number, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.removeStudent(+id, +studentId); await logAudit(this.logService, req, { @@ -270,7 +270,7 @@ export class ClassesController { @Post(':id/teachers') @RequirePermission('class:edit') - async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: any) { + async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: AuthenticatedRequest) { const result = await this.service.addTeacher(+id, dto); await logAudit(this.logService, req, { module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`, @@ -293,7 +293,7 @@ export class ClassesController { async removeTeacherAssignment( @Param('id', ParseIntPipe) id: number, @Param('assignmentId', ParseIntPipe) assignmentId: number, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.removeTeacherAssignment(+id, +assignmentId); await logAudit(this.logService, req, { @@ -307,7 +307,7 @@ export class ClassesController { async removeTeacher( @Param('id', ParseIntPipe) id: number, @Param('userId', ParseIntPipe) userId: number, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.removeTeacher(+id, +userId); await logAudit(this.logService, req, { diff --git a/apps/server/src/classes/dto/class.dto.ts b/apps/server/src/classes/dto/class.dto.ts index 3d1e837..df09081 100644 --- a/apps/server/src/classes/dto/class.dto.ts +++ b/apps/server/src/classes/dto/class.dto.ts @@ -159,7 +159,7 @@ export class QueryClassDto { if (typeof value === 'boolean') return value; if (value === 'true' || value === '1') return true; if (value === 'false' || value === '0') return false; - return value; + return value as boolean; }) @IsBoolean() isArchived?: boolean; diff --git a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts index d64be00..4c8b6ba 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts @@ -25,6 +25,12 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +interface AuthenticatedRequest { + user?: { id: number; username: string }; + headers?: Record; + connection?: { remoteAddress?: string }; +} + @UseGuards(JwtAuthGuard) @Controller('classroom-rentals') export class ClassroomRentalsController { @@ -102,7 +108,7 @@ export class ClassroomRentalsController { @Post() @RequirePermission('rental:create') - async create(@Body() dto: CreateRentalDto, @Request() req: any) { + async create(@Body() dto: CreateRentalDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto, req.user?.id); await logAudit(this.logService, req, { module: '教室租赁', action: '新增租赁', targetId: result.id, targetType: 'classroom-rental', detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`, @@ -112,7 +118,7 @@ export class ClassroomRentalsController { @Put(':id') @RequirePermission('rental:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRentalDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRentalDto, @Request() req: AuthenticatedRequest) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto), @@ -122,7 +128,7 @@ export class ClassroomRentalsController { @Put(':id/cancel') @RequirePermission('rental:edit') - async cancel(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async cancel(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.cancel(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental', @@ -132,7 +138,7 @@ export class ClassroomRentalsController { @Put(':id/end') @RequirePermission('rental:edit') - async end(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async end(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.end(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental', @@ -142,7 +148,7 @@ export class ClassroomRentalsController { @Delete(':id') @RequirePermission('rental:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental', @@ -152,7 +158,7 @@ export class ClassroomRentalsController { @Delete(':id/permanent') @RequirePermission('rental:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复', @@ -177,7 +183,7 @@ export class ClassroomRentalsController { async uploadContract( @Param('id', ParseIntPipe) id: number, @UploadedFile() file: Express.Multer.File, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { if (!file) throw new BadRequestException('请上传合同文件'); const result = await this.service.attachContract(+id, file); @@ -202,7 +208,7 @@ export class ClassroomRentalsController { @Delete(':id/contract') @RequirePermission('rental:edit') - async deleteContract(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async deleteContract(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.removeContract(+id); await logAudit(this.logService, req, { module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental', diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index 2e48720..8cd9f4c 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -19,6 +19,19 @@ import * as path from 'path'; import * as fs from 'fs'; import { randomBytes } from 'crypto'; +/** getRawMany 原始行:驱动可能返回 string/number,date 列可能是 string 或 Date */ +interface AgentRentalRawRow { + id: string | number; + classroomName: string | null; + lesseeOrganizationName: string | null; + startDate: string | Date; + endDate: string | Date; + dailyRate: string | number | null; + totalAmount: string | number | null; + status: string; + contractName: string | null; +} + // 预设色板(与 organizations.service 保持一致,作为颜色兜底) function rentalConflictError( message: string, @@ -142,7 +155,7 @@ export class ClassroomRentalsService { const rows = await qb .orderBy('r.startDate', 'DESC') .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) - .getRawMany>(); + .getRawMany(); return rows.map((row) => ({ id: Number(row.id), classroomName: row.classroomName == null ? '' : String(row.classroomName), diff --git a/apps/server/src/classrooms/classrooms.controller.ts b/apps/server/src/classrooms/classrooms.controller.ts index 1cd7e6d..347b8b7 100644 --- a/apps/server/src/classrooms/classrooms.controller.ts +++ b/apps/server/src/classrooms/classrooms.controller.ts @@ -25,6 +25,21 @@ import { RequirePermission } from '../auth/decorators/permission.decorator'; import * as ExcelJS from 'exceljs'; import { CLASSROOM_TEMPLATE_COLUMNS } from './classroom-template'; +interface AuthenticatedRequest { + user?: { id: number; username: string }; + headers?: Record; + connection?: { remoteAddress?: string }; +} + +/** + * exceljs 单元格标量文本。保持原 `String(value || '')` 语义。 + * CellValue 联合类型含富文本/超链接对象,实际导入数据均为标量,故在辅助函数内局部豁免。 + */ +function cellValueText(value: unknown): string { + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本对象,实际导入数据为标量 + return String(value || ''); +} + @UseGuards(JwtAuthGuard) @Controller('classrooms') export class ClassroomsController { @@ -104,7 +119,7 @@ export class ClassroomsController { @Post() @RequirePermission('classroom:create') - async create(@Body() dto: CreateClassroomDto, @Request() req: any) { + async create(@Body() dto: CreateClassroomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto); await logAudit(this.logService, req, { module: '教室', action: '新增教室', targetId: result.id, targetType: 'classroom', detail: dto.name, @@ -114,7 +129,7 @@ export class ClassroomsController { @Put(':id') @RequirePermission('classroom:edit') - async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: any) { + async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '教室', action: '编辑教室', targetId: +id, targetType: 'classroom', detail: JSON.stringify(dto), @@ -124,7 +139,7 @@ export class ClassroomsController { @Delete(':id') @RequirePermission('classroom:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom', @@ -134,7 +149,7 @@ export class ClassroomsController { @Delete(':id/permanent') @RequirePermission('classroom:purge') - async purge(@Param('id') id: string, @Request() req: any) { + async purge(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '教室', action: '永久删除教室', targetId: +id, targetType: 'classroom', detail: '物理删除,不可恢复', @@ -144,7 +159,7 @@ export class ClassroomsController { @Put(':id/restore') @RequirePermission('classroom:edit') - async restore(@Param('id') id: string, @Request() req: any) { + async restore(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.restore(+id); await logAudit(this.logService, req, { module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom', @@ -155,19 +170,25 @@ export class ClassroomsController { @Post('import') @RequirePermission('classroom:create') @UseInterceptors(FileInterceptor('file')) - async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; - const rows: any[] = []; + const rows: { + name: string; + building?: string; + floor?: number; + roomType?: string; + capacity?: number; + }[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; rows.push({ - name: String(row.getCell(1).value || ''), - building: String(row.getCell(2).value || '') || undefined, + name: cellValueText(row.getCell(1).value), + building: cellValueText(row.getCell(2).value) || undefined, floor: Number(row.getCell(3).value) || undefined, - roomType: String(row.getCell(4).value || '') || undefined, + roomType: cellValueText(row.getCell(4).value) || undefined, capacity: Number(row.getCell(5).value) || undefined, }); }); diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index 92339bd..ddbb607 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -7,6 +7,25 @@ import { ClassSchedule } from '../entities/class-schedule.entity'; import { AttendanceDevice } from '../entities/attendance-device.entity'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; +/** getRawMany 原始行:驱动可能返回 string/number,date 列可能是 string 或 Date */ +interface ScheduleUsageRawRow { + classroomId: string | number; + startTime: string; + endTime: string; + startDate: string | Date; + endDate: string | Date; + weekDay: string | number; + subject: string | null; + className: string | null; +} + +interface RentalUsageRawRow { + classroomId: string | number; + startDate: string | Date; + endDate: string | Date; + tenantName: string | null; +} + @Injectable() export class ClassroomsService { constructor( @@ -255,7 +274,7 @@ export class ClassroomsService { .andWhere('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.endDate >= :today', { today: todayStr }) - .getRawMany(); + .getRawMany(); for (const schedule of schedules) { const classroomId = Number(schedule.classroomId); @@ -291,7 +310,7 @@ export class ClassroomsService { .where('r.classroomId IN (:...ids)', { ids: classroomIds }) .andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }) .andWhere('r.endDate >= :today', { today: todayStr }) - .getRawMany(); + .getRawMany(); for (const rental of rentals) { const classroomId = Number(rental.classroomId); diff --git a/apps/server/src/common/request-utils.ts b/apps/server/src/common/request-utils.ts index 4132819..5ba570c 100644 --- a/apps/server/src/common/request-utils.ts +++ b/apps/server/src/common/request-utils.ts @@ -1,13 +1,19 @@ +/** 从请求对象提取 IP / UA 所需的最小结构。 */ +export interface RequestInfoSource { + headers?: Record; + connection?: { remoteAddress?: string }; +} + /** * 从请求对象中提取客户端 IP 和 UserAgent */ -export function extractRequestInfo(req: any): { ipAddress: string; userAgent: string } { +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 userAgent = (req.headers?.['user-agent'] || '').substring(0, 500); + const userAgent = String(req.headers?.['user-agent'] || '').substring(0, 500); return { ipAddress, userAgent }; } diff --git a/apps/server/src/dashboard/dashboard-queries.service.ts b/apps/server/src/dashboard/dashboard-queries.service.ts index be279ec..5b4e06f 100644 --- a/apps/server/src/dashboard/dashboard-queries.service.ts +++ b/apps/server/src/dashboard/dashboard-queries.service.ts @@ -57,12 +57,12 @@ async getAttendanceTrend( .groupBy('a.attendanceDate') .addGroupBy('a.status') .orderBy('a.attendanceDate', 'ASC') - .getRawMany(); + .getRawMany<{ date: string; status: string; count: string | number }>(); const dayMap = new Map(); for (const row of rows) { const d = dayMap.get(row.date) || { total: 0, present: 0 }; - const cnt = parseInt(row.count, 10); + const cnt = parseInt(String(row.count), 10); d.total += cnt; if (row.status === 'present') d.present += cnt; dayMap.set(row.date, d); @@ -92,11 +92,11 @@ async getIncomeTrend( .where('b.status = :paid', { paid: 'paid' }) .andWhere('b.periodStart >= :start', { start: `${m}-01` }) .andWhere('b.periodStart < :end', { end: nextMonth(m) }) - .getRawOne(); + .getRawOne<{ total: string | number | null }>(); results.push({ month: m, - amount: parseFloat(row?.total || '0'), + amount: parseFloat(String(row?.total || '0')), }); } @@ -216,15 +216,21 @@ async getClassAttendanceRanking( .addSelect('COUNT(*)', 'count'); applyClassScope(qb, 'a', accessibleClassIds); qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status'); - const raw = await qb.getRawMany(); + const raw = await qb.getRawMany<{ + classId: string | number | null; + className: string | null; + status: string; + count: string | number; + }>(); const classMap = new Map(); for (const r of raw) { if (!r.classId) continue; - if (!classMap.has(Number(r.classId))) - classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 }); - const entry = classMap.get(Number(r.classId))!; - const n = parseInt(r.count, 10); + const classId = Number(r.classId); + if (!classMap.has(classId)) + classMap.set(classId, { className: r.className ?? '', present: 0, total: 0 }); + const entry = classMap.get(classId)!; + const n = parseInt(String(r.count), 10); entry.total += n; if (r.status === 'present') entry.present += n; } diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index b7309aa..d940dfe 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -100,7 +100,7 @@ export class DashboardService { .createQueryBuilder('r') .select('SUM(r.capacity)', 'total') .where('r.status != :archived', { archived: 'archived' }); - const totalCapacity = await capQb.getRawOne(); + const totalCapacity = await capQb.getRawOne<{ total: string | number | null }>(); // MySQL 的 SUM() 聚合默认以字符串返回,需显式转成 number const cap = Number(totalCapacity?.total ?? 0) || 0; const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0; @@ -111,7 +111,11 @@ export class DashboardService { .addSelect('COUNT(*)', 'count') .addSelect('SUM(b.totalAmount)', 'total') .groupBy('b.status'); - const billStats = await billStatsQb.getRawMany(); + const billStats = await billStatsQb.getRawMany<{ + status: string; + count: string | number; + total: string | number; + }>(); // New fields const classroomCount = await this.classroomRepo.count({ where: {} }); @@ -122,8 +126,8 @@ export class DashboardService { .where('s.status = :active', { active: 'active' }) .andWhere('s.startDate <= :today', { today: todayStr }) .andWhere('s.endDate >= :today', { today: todayStr }); - const occResult = await occQb.getRawOne(); - const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10); + const occResult = await occQb.getRawOne<{ cnt: string | number | null }>(); + const occupiedClassrooms = parseInt(String(occResult?.cnt || '0'), 10); const classroomOccupancyRate = classroomCount > 0 ? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) : 0; @@ -134,18 +138,18 @@ export class DashboardService { .where('a.attendanceDate = :today', { today: todayStr }); this.applyClassScope(attTodayQb, 'a', accessibleClassIds); attTodayQb.groupBy('a.status'); - const attTodayStats = await attTodayQb.getRawMany(); + const attTodayStats = await attTodayQb.getRawMany<{ status: string; count: string | number }>(); const todayPresent = attTodayStats .filter((r) => r.status === 'present') - .reduce((sum, r) => sum + parseInt(r.count, 10), 0); + .reduce((sum, r) => sum + parseInt(String(r.count), 10), 0); const incomeQb = this.billRepo .createQueryBuilder('b') .select('SUM(b.totalAmount)', 'total') .where('b.status = :paid', { paid: 'paid' }) .andWhere('b.periodStart >= :start', { start: `${currentMonth}-01` }) .andWhere('b.periodStart < :end', { end: this.nextMonth(currentMonth) }); - const incomeResult = await incomeQb.getRawOne(); - const monthlyIncome = parseFloat(incomeResult?.total || '0'); + const incomeResult = await incomeQb.getRawOne<{ total: string | number | null }>(); + const monthlyIncome = parseFloat(String(incomeResult?.total || '0')); const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds); const incomeTrend = await this.getIncomeTrend(currentMonth); @@ -157,15 +161,15 @@ export class DashboardService { const teacherResult = await this.classTeacherRepo .createQueryBuilder('ct') .select('COUNT(DISTINCT ct.userId)', 'cnt') - .getRawOne(); - const teacherCount = parseInt(teacherResult?.cnt || '0', 10); + .getRawOne<{ cnt: string | number | null }>(); + const teacherCount = parseInt(String(teacherResult?.cnt || '0'), 10); const pendingQb = this.depositRepo .createQueryBuilder('d') .select('SUM(d.amount)', 'total') .where('d.status = :paid', { paid: 'paid' }); - const pendingResult = await pendingQb.getRawOne(); - const pendingDeposits = parseFloat(pendingResult?.total || '0'); + const pendingResult = await pendingQb.getRawOne<{ total: string | number | null }>(); + const pendingDeposits = parseFloat(String(pendingResult?.total || '0')); const activeRentals = await this.rentalRepo.count({ where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) }, @@ -177,11 +181,13 @@ export class DashboardService { .select('r.building', 'building') .addSelect('COUNT(*)', 'count') .where('o.checkOutDate IS NULL'); - const occupancyByBuilding = await occByBldQb.groupBy('r.building').getRawMany(); + const occupancyByBuilding = await occByBldQb + .groupBy('r.building') + .getRawMany<{ building: string | null; count: string | number }>(); const attendanceByStatus = attTodayStats.reduce( (acc, r) => { - acc[r.status] = parseInt(r.count, 10); + acc[r.status] = parseInt(String(r.count), 10); return acc; }, {} as Record, @@ -193,7 +199,9 @@ export class DashboardService { .addSelect('SUM(e.amount)', 'total') .where('e.periodStart >= :start', { start: `${currentMonth}-01` }) .andWhere('e.periodEnd <= :end', { end: this.nextMonth(currentMonth) }); - const expenseByType = await expByTypeQb.groupBy('e.expenseType').getRawMany(); + const expenseByType = await expByTypeQb + .groupBy('e.expenseType') + .getRawMany<{ type: string; total: string | number }>(); return { totalRooms, @@ -288,7 +296,7 @@ export class DashboardService { .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) .groupBy('s.classroomId'); - const schedules = await schedQb.getRawMany(); + const schedules = await schedQb.getRawMany<{ classroomId: number; weekDays: string | number }>(); const rentalQb = this.rentalRepo .createQueryBuilder('r') .select('r.classroomId', 'classroomId') @@ -296,11 +304,11 @@ export class DashboardService { .where('r.status = :active', { active: 'active' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) .groupBy('r.classroomId'); - const rentals = await rentalQb.getRawMany(); + const rentals = await rentalQb.getRawMany<{ classroomId: number; rentalCount: string | number }>(); const sMap: Record = {}; const rMap: Record = {}; - for (const s of schedules) sMap[s.classroomId] = parseInt(s.weekDays, 10); - for (const r of rentals) rMap[r.classroomId] = parseInt(r.rentalCount, 10); + for (const s of schedules) sMap[s.classroomId] = parseInt(String(s.weekDays), 10); + for (const r of rentals) rMap[r.classroomId] = parseInt(String(r.rentalCount), 10); return classrooms.map((c) => ({ name: c.name, building: c.building || '', @@ -344,7 +352,7 @@ export class DashboardService { .where('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }); - const schedResult = await schedQb.getRawOne(); + const schedResult = await schedQb.getRawOne<{ cnt: string | number | null }>(); // Count classrooms with active rentals today const rentalQb = this.rentalRepo @@ -352,7 +360,7 @@ export class DashboardService { .select('COUNT(DISTINCT r.classroomId)', 'cnt') .where('r.status = :active', { active: 'active' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }); - const rentalResult = await rentalQb.getRawOne(); + const rentalResult = await rentalQb.getRawOne<{ cnt: string | number | null }>(); // Combine: use Set merge of both const combinedQb = this.scheduleRepo @@ -362,7 +370,7 @@ export class DashboardService { .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) .groupBy('s.classroomId'); - const schedIds = await combinedQb.getRawMany(); + const schedIds = await combinedQb.getRawMany<{ classroomId: number }>(); const combinedRentalQb = this.rentalRepo .createQueryBuilder('r') @@ -370,15 +378,15 @@ export class DashboardService { .where('r.status = :active', { active: 'active' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) .groupBy('r.classroomId'); - const rentalIds = await combinedRentalQb.getRawMany(); + const rentalIds = await combinedRentalQb.getRawMany<{ classroomId: number }>(); const allInUseIds = new Set([ ...schedIds.map((s) => s.classroomId), ...rentalIds.map((r) => r.classroomId), ]); - const scheduleCount = parseInt(schedResult?.cnt || '0', 10); - const rentalCount = parseInt(rentalResult?.cnt || '0', 10); + const scheduleCount = parseInt(String(schedResult?.cnt || '0'), 10); + const rentalCount = parseInt(String(rentalResult?.cnt || '0'), 10); const inUseCount = allInUseIds.size; const utilizationRate = totalClassrooms > 0 ? ((inUseCount / totalClassrooms) * 100).toFixed(1) : '0'; diff --git a/apps/server/src/database/database-migrations.attendance.ts b/apps/server/src/database/database-migrations.attendance.ts index 61f918f..ad72531 100644 --- a/apps/server/src/database/database-migrations.attendance.ts +++ b/apps/server/src/database/database-migrations.attendance.ts @@ -138,7 +138,7 @@ export async function migrateMySQLAttendanceFKs( // Drop any existing FK constraint on schedule_id or class_id const fkColumns = ['schedule_id', 'class_id']; for (const col of fkColumns) { - const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query( + const fkRows = (await runner.query( ` SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE @@ -148,7 +148,7 @@ export async function migrateMySQLAttendanceFKs( AND REFERENCED_TABLE_NAME IS NOT NULL `, [col], - ); + )) as Array<{ CONSTRAINT_NAME: string }>; for (const row of fkRows) { try { @@ -168,7 +168,7 @@ export async function migrateMySQLAttendanceFKs( ]; for (const c of constraints) { // Only skip if RESTRICT constraint is already confirmed via information_schema - const existing: Array<{ DELETE_RULE: string }> = await runner.query( + const existing = (await runner.query( ` SELECT DELETE_RULE FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS @@ -177,7 +177,7 @@ export async function migrateMySQLAttendanceFKs( AND CONSTRAINT_NAME = ? `, [c.name], - ); + )) as Array<{ DELETE_RULE: string }>; if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') { logger.log(`考勤场次删除保护约束已存在: ${c.name}`); diff --git a/apps/server/src/database/database-migrations.backfill.ts b/apps/server/src/database/database-migrations.backfill.ts index 2444b36..744d193 100644 --- a/apps/server/src/database/database-migrations.backfill.ts +++ b/apps/server/src/database/database-migrations.backfill.ts @@ -3,6 +3,16 @@ import { DataSource } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; import { withQueryRunner } from './database-migrations.runner'; +/** 迁移脚本中用到的 organizations 表最小行结构。 */ +interface OrganizationRow { + id: number; +} + +/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */ +function stringify(value: unknown): string { + return String(value); +} + export async function backfillOrganizations( dataSource: DataSource, ): Promise { @@ -17,8 +27,8 @@ export async function backfillOrganizations( const tableNames = new Set(tables.map((table) => table.name)); if (!tableNames.has('organizations')) return; - const organizationRows = () => - runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1'); + const organizationRows = (): Promise => + runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1') as Promise; let host = (await organizationRows())[0]; if (!host) { await runner.query( @@ -39,14 +49,17 @@ export async function backfillOrganizations( if (!host) return; if (tableNames.has('tenants')) { - const legacyTenants: Array> = - await runner.query('SELECT * FROM tenants'); + const legacyTenants = (await runner.query('SELECT * FROM tenants')) as Array< + Record + >; for (const legacy of legacyTenants) { - const name = String(legacy.name || '').trim(); + const name = stringify(legacy.name || '').trim(); if (!name) continue; - let external = ( - await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name]) - )[0]; + const externalRows = (await runner.query( + 'SELECT * FROM organizations WHERE name = ? LIMIT 1', + [name], + )) as OrganizationRow[]; + let external = externalRows[0]; if (!external) { await runner.query( `INSERT INTO organizations (public_id, code, name, is_host, contact_name, phone, color, notes, status, created_at, updated_at) @@ -64,7 +77,7 @@ export async function backfillOrganizations( ], ); external = ( - await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name]) + (await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])) as OrganizationRow[] )[0]; } if (!external) continue; @@ -158,7 +171,11 @@ export async function normalizeClassDates( .map((column) => `${column} = ${normalizedDate(column)}`) .join(',\n '); const predicates = columns.map((column) => needsNormalization(column)).join('\n OR '); - const result = await dataSource.transaction((manager) => + interface UpdateResultLike { + changes?: number; + affectedRows?: number; + } + const result = await dataSource.transaction((manager) => manager.query(` UPDATE classes SET diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts index 060300e..0e4678e 100644 --- a/apps/server/src/deposits/deposits.controller.ts +++ b/apps/server/src/deposits/deposits.controller.ts @@ -27,6 +27,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} @UseGuards(JwtAuthGuard) @Controller('deposits') @@ -77,7 +84,7 @@ export class DepositsController { @Post() @RequirePermission('deposit:create') - async create(@Body() dto: CreateDepositDto, @Request() req: any) { + async create(@Body() dto: CreateDepositDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto, req.user?.id); await logAudit(this.logService, req, { module: '押金管理', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`, @@ -89,7 +96,7 @@ export class DepositsController { @Post('batch') @RequirePermission('deposit:create') - async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) { + async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchCreate(dto, req.user?.id); await logAudit(this.logService, req, { module: '押金管理', action: '批量收取押金', targetType: 'deposit', detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`, @@ -140,7 +147,7 @@ export class DepositsController { @Put(':id/refund') @RequirePermission('deposit:refund') - async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) { + async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: AuthenticatedRequest) { const result = await this.service.refund(id, dto, req.user?.id); await logAudit(this.logService, req, { module: '押金管理', action: '退还押金', targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`, @@ -167,7 +174,7 @@ export class DepositsController { @Delete(':id') @RequirePermission('deposit:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(id); await logAudit(this.logService, req, { module: '押金管理', action: '归档押金记录', targetId: id, targetType: 'deposit', @@ -177,7 +184,7 @@ export class DepositsController { @Delete(':id/permanent') @RequirePermission('deposit:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(id); await logAudit(this.logService, req, { module: '押金管理', action: '永久删除押金', targetId: id, targetType: 'deposit', detail: '物理删除,不可恢复', diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index 8dc741a..b4d4bce 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -10,6 +10,9 @@ import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); +/** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */ +type RawScalarRow = Record; + const capacityRoomTypeText: Record = { 1: '单人间', 2: '二人间', @@ -99,7 +102,7 @@ export class DepositsService { } } - const rows = await qb.getRawMany(); + const rows = await qb.getRawMany(); return rows.map((row) => ({ studentId: Number(row.studentId), studentName: row.studentName, @@ -107,9 +110,12 @@ export class DepositsService { roomId: Number(row.roomId), roomNumber: row.roomNumber, building: row.building ?? null, - roomType: normalizeRoomType(row.roomType, row.capacity), + roomType: normalizeRoomType( + row.roomType == null ? null : String(row.roomType), + row.capacity == null ? null : Number(row.capacity), + ), capacity: Number(row.capacity), - depositAmount: money(row.depositAmount), + depositAmount: money(row.depositAmount == null ? null : Number(row.depositAmount)), })); } @@ -198,7 +204,7 @@ export class DepositsService { const rows = await qb .orderBy('d.createdAt', 'DESC') .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) - .getRawMany>(); + .getRawMany(); return rows.map((row) => ({ id: Number(row.id), studentName: row.studentName == null ? '' : String(row.studentName), diff --git a/apps/server/src/entities/jinshuju-match-rule.entity.ts b/apps/server/src/entities/jinshuju-match-rule.entity.ts index 5e01cb3..59c5d25 100644 --- a/apps/server/src/entities/jinshuju-match-rule.entity.ts +++ b/apps/server/src/entities/jinshuju-match-rule.entity.ts @@ -22,7 +22,7 @@ const mappingTransformer = { }, from(value: string | null): JinshujuFieldMapping { if (!value) return {}; - return JSON.parse(value); + return JSON.parse(value) as JinshujuFieldMapping; }, }; diff --git a/apps/server/src/exams/dto/exam.dto.ts b/apps/server/src/exams/dto/exam.dto.ts index 93dc52a..e280865 100644 --- a/apps/server/src/exams/dto/exam.dto.ts +++ b/apps/server/src/exams/dto/exam.dto.ts @@ -29,7 +29,7 @@ export class QueryExamDto { if (typeof value === 'boolean') return value; if (value === 'true' || value === '1') return true; if (value === 'false' || value === '0') return false; - return value; + return value as boolean; }) @IsBoolean() isArchived?: boolean; diff --git a/apps/server/src/expense-types/expense-types.controller.ts b/apps/server/src/expense-types/expense-types.controller.ts index 3813d15..724658a 100644 --- a/apps/server/src/expense-types/expense-types.controller.ts +++ b/apps/server/src/expense-types/expense-types.controller.ts @@ -5,6 +5,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} @UseGuards(JwtAuthGuard) @Controller('expense-types') @@ -28,7 +35,7 @@ export class ExpenseTypesController { @Post() @RequirePermission('expense:create') - async create(@Body() dto: CreateExpenseTypeDto, @Request() req: any) { + async create(@Body() dto: CreateExpenseTypeDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); await this.logService.log({ @@ -47,7 +54,7 @@ export class ExpenseTypesController { @Put(':id') @RequirePermission('expense:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseTypeDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); await this.logService.log({ @@ -66,7 +73,7 @@ export class ExpenseTypesController { @Delete(':id') @RequirePermission('expense:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); await this.service.remove(+id); await this.logService.log({ diff --git a/apps/server/src/expenses/expense-operations.service.ts b/apps/server/src/expenses/expense-operations.service.ts index 2f6e2ad..fac81fb 100644 --- a/apps/server/src/expenses/expense-operations.service.ts +++ b/apps/server/src/expenses/expense-operations.service.ts @@ -290,8 +290,10 @@ export class ExpenseOperationsService { skipped++; errors.push(`第${rowNum}行: ${row.roomNumber} 无有效金额`); } - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`); + } catch (e: unknown) { + errors.push( + `第${rowNum}行: ${row.roomNumber} 导入失败 - ${e instanceof Error ? e.message : '未知错误'}`, + ); skipped++; } } @@ -404,8 +406,10 @@ export class ExpenseOperationsService { // 校验金额 try { this.assertPositiveAmount(row.amount); - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.studentName} ${e.message}`); + } catch (e: unknown) { + errors.push( + `第${rowNum}行: ${row.studentName} ${e instanceof Error ? e.message : '未知错误'}`, + ); skipped++; continue; } @@ -422,8 +426,10 @@ export class ExpenseOperationsService { ); imported++; - } catch (e: any) { - errors.push(`第${rowNum}行: ${row.studentName} 导入失败 - ${e.message}`); + } catch (e: unknown) { + errors.push( + `第${rowNum}行: ${row.studentName} 导入失败 - ${e instanceof Error ? e.message : '未知错误'}`, + ); skipped++; } } diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 76aa6ad..170992c 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -34,23 +34,60 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; +import type { AuthenticatedUser } from '../authorization'; +import { PersonalExpense } from '../entities/personal-expense.entity'; import * as ExcelJS from 'exceljs'; +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} + +/** ExcelJS 单元格解析出的标量值 */ +type CellScalar = string | number | boolean | Date | null | undefined; + +interface UtilityImportRow { + periodStr: string; + roomNumber: string; + electricityAmount: number; + electricityFee: number; + waterAmount: number; + waterFee: number; + totalFee: number; +} + +interface PersonalImportRow { + studentName: string; + expenseType: string; + amount: number; + expenseDate: string; + description?: string; +} + /** 提取 ExcelJS 单元格的真实值,兼容公式、富文本、日期、超链接等情况 */ -function readCell(cell: ExcelJS.Cell): any { - let v: any = cell?.value; +function readCell(cell: ExcelJS.Cell | undefined): CellScalar { + let v: unknown = cell?.value; if (v == null) return ''; - if (typeof v === 'object') { + if (typeof v === 'object' && !(v instanceof Date)) { + const record = v as Record; // 公式单元格:{ formula, result } - if ('result' in v) v = v.result; + if ('result' in record) v = record.result; // 富文本:{ richText: [...] } - else if ('richText' in v && Array.isArray(v.richText)) { - return v.richText.map((r: any) => r.text || '').join(''); + else if ('richText' in record && Array.isArray(record.richText)) { + return record.richText + .map((r) => + r !== null && typeof r === 'object' && 'text' in r + ? (r as { text?: string }).text || '' + : '', + ) + .join(''); } // 超链接:{ text, hyperlink } - else if ('text' in v) v = v.text; + else if ('text' in record) v = record.text; // 错误值:{ error: '#DIV/0!' } - else if ('error' in v) return ''; + else if ('error' in record) return ''; + else return ''; } if (v instanceof Date) { const y = v.getFullYear(); @@ -58,7 +95,8 @@ function readCell(cell: ExcelJS.Cell): any { const d = String(v.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } - return v; + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v; + return v == null ? v : ''; } function readCellNum(cell: ExcelJS.Cell): number { @@ -95,7 +133,7 @@ export class ExpensesController { @Post('student-utility') @RequirePermission('expense:create') - async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) { + async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: AuthenticatedRequest) { const result = await this.service.createStudentUtilityBill(dto, req.user?.id); await logAudit(this.logService, req, { module: '费用管理', action: '录入学生水电费并出账', targetId: result.bill.id, targetType: 'bill', detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`, @@ -105,7 +143,7 @@ export class ExpensesController { @Post('room') @RequirePermission('expense:create') - async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) { + async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: AuthenticatedRequest) { const result = await this.service.createRoomExpense(dto, req.user?.id); await logAudit(this.logService, req, { module: '费用管理', action: '录入费用', targetId: result.id, targetType: 'room_expense', detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`, @@ -115,7 +153,7 @@ export class ExpensesController { @Post('room/batch') @RequirePermission('expense:create') - async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: any) { + async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchCreateRoomExpenses(dto, req.user?.id); await logAudit(this.logService, req, { module: '费用管理', action: '批量录入费用', detail: JSON.stringify(dto), @@ -131,7 +169,7 @@ export class ExpensesController { @Delete('room/:id') @RequirePermission('expense:delete') - async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.deleteRoomExpense(id); await logAudit(this.logService, req, { module: '费用管理', action: '归档费用', targetId: id, targetType: 'room_expense', @@ -141,7 +179,7 @@ export class ExpensesController { @Post('room/batch-delete') @RequirePermission('expense:delete') - async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchDeleteRoomExpenses(body.ids || []); await logAudit(this.logService, req, { module: '费用管理', action: '批量归档宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -151,7 +189,7 @@ export class ExpensesController { @Delete('room/:id/permanent') @RequirePermission('expense:purge') - async purgeRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purgeRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purgeRoomExpense(id); await logAudit(this.logService, req, { module: '费用管理', action: '永久删除宿舍费用', targetId: id, targetType: 'room_expense', detail: '物理删除,不可恢复', @@ -161,7 +199,7 @@ export class ExpensesController { @Post('room/batch-permanent-delete') @RequirePermission('expense:purge') - async batchPurgeRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurgeRoomExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurgeRoomExpenses(body.ids || []); await logAudit(this.logService, req, { module: '费用管理', action: '批量永久删除宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -172,7 +210,7 @@ export class ExpensesController { @Put('room/batch-restore') @RequirePermission('expense:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: any) { + async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestoreRoomExpenses(dto.ids); await logAudit(this.logService, req, { module: '费用管理', action: '批量恢复宿舍费用', detail: `IDs: ${dto.ids.join(',')}`, @@ -185,7 +223,7 @@ export class ExpensesController { async updateRoomExpense( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoomExpenseDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.updateRoomExpense(id, dto); await logAudit(this.logService, req, { @@ -196,7 +234,7 @@ export class ExpensesController { @Post('personal') @RequirePermission('expense:create') - async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: any) { + async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: AuthenticatedRequest) { const result = await this.service.createPersonalExpense(dto, req.user?.id); await logAudit(this.logService, req, { module: '费用管理', action: '录入费用', detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`, @@ -212,7 +250,7 @@ export class ExpensesController { @Delete('personal/:id') @RequirePermission('expense:delete') - async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.deletePersonalExpense(id); await logAudit(this.logService, req, { module: '费用管理', action: '归档费用', targetId: id, @@ -222,7 +260,7 @@ export class ExpensesController { @Post('personal/batch-delete') @RequirePermission('expense:delete') - async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchDeletePersonalExpenses(body.ids || []); await logAudit(this.logService, req, { module: '费用管理', action: '批量归档个人费用', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -232,7 +270,7 @@ export class ExpensesController { @Delete('personal/:id/permanent') @RequirePermission('expense:purge') - async purgePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purgePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purgePersonalExpense(id); await logAudit(this.logService, req, { module: '费用管理', action: '永久删除个人费用', targetId: id, targetType: 'personal_expense', detail: '物理删除,不可恢复', @@ -242,7 +280,7 @@ export class ExpensesController { @Post('personal/batch-permanent-delete') @RequirePermission('expense:purge') - async batchPurgePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurgePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurgePersonalExpenses(body.ids || []); await logAudit(this.logService, req, { module: '费用管理', action: '批量永久删除个人费用', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -253,7 +291,7 @@ export class ExpensesController { @Put('personal/batch-restore') @RequirePermission('expense:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: any) { + async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestorePersonalExpenses(dto.ids); await logAudit(this.logService, req, { module: '费用管理', action: '批量恢复个人费用', detail: `IDs: ${dto.ids.join(',')}`, @@ -266,7 +304,7 @@ export class ExpensesController { async updatePersonalExpense( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdatePersonalExpenseDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.updatePersonalExpense(id, dto); await logAudit(this.logService, req, { @@ -314,11 +352,11 @@ export class ExpensesController { @Post('utility/import') @RequirePermission('expense:create') @UseInterceptors(FileInterceptor('file')) - async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; - const rows: any[] = []; + const rows: UtilityImportRow[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; // 跳过表头 const roomNumber = readCellStr(row.getCell(3)); @@ -379,11 +417,11 @@ export class ExpensesController { @Post('personal/import') @RequirePermission('expense:create') @UseInterceptors(FileInterceptor('file')) - async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const ws = workbook.worksheets[0]; - const rows: any[] = []; + const rows: PersonalImportRow[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; const studentName = readCellStr(row.getCell(1)); @@ -417,7 +455,7 @@ export class ExpensesController { { header: '说明', key: 'description', width: 30 }, ]; ws.getRow(1).font = { bold: true }; - data.forEach((d: any) => { + data.forEach((d: PersonalExpense) => { ws.addRow({ studentName: d.student?.name || '', expenseType: d.expenseType, diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index 0a77692..1b0d666 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -14,6 +14,9 @@ import { import { BillsService } from '../bills/bills.service'; import { ExpenseOperationsService } from './expense-operations.service'; +/** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */ +type RawScalarRow = Record; + @Injectable() export class ExpensesService { @@ -154,7 +157,7 @@ export class ExpensesService { const roomRows = await roomQb .orderBy('e.createdAt', 'DESC') .limit(limit) - .getRawMany>(); + .getRawMany(); const personalQb = this.personalExpRepo .createQueryBuilder('e') @@ -186,7 +189,7 @@ export class ExpensesService { const personalRows = await personalQb .orderBy('e.createdAt', 'DESC') .limit(limit) - .getRawMany>(); + .getRawMany(); return { roomExpenses: roomRows.map((row) => ({ diff --git a/apps/server/src/imports/imports.service.spec.ts b/apps/server/src/imports/imports.service.spec.ts index 7a618d4..6809fd5 100644 --- a/apps/server/src/imports/imports.service.spec.ts +++ b/apps/server/src/imports/imports.service.spec.ts @@ -8,7 +8,6 @@ import { ImportRun } from './entities/import-run.entity'; import { ImportStep } from './entities/import-step.entity'; import { ImportRow } from './entities/import-row.entity'; import { ImportsService } from './imports.service'; -import * as workbookModule from './imports.workbook'; import type { ParsedImportFile } from './imports.types'; function makeRowsRepo() { diff --git a/apps/server/src/imports/imports.service.ts b/apps/server/src/imports/imports.service.ts index 92e5136..b332356 100644 --- a/apps/server/src/imports/imports.service.ts +++ b/apps/server/src/imports/imports.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { ImportRun } from './entities/import-run.entity'; diff --git a/apps/server/src/imports/imports.workbook.spec.ts b/apps/server/src/imports/imports.workbook.spec.ts index f8a7c50..137f032 100644 --- a/apps/server/src/imports/imports.workbook.spec.ts +++ b/apps/server/src/imports/imports.workbook.spec.ts @@ -7,7 +7,7 @@ import { parseSheets } from './imports.workbook'; async function xlsxBuffer(rows: Array>): Promise { const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet('名单'); - rows.forEach((row, index) => worksheet.addRow(row)); + rows.forEach((row, _index) => worksheet.addRow(row)); return (await workbook.xlsx.writeBuffer()) as Buffer; } diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index a291654..0c300d5 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -11,6 +11,12 @@ import { SaveIntegrationConfigDto, } from './dto/config.dto'; +/** 第三方配置在 content JSON 中的存储结构。 */ +interface StoredConfigShape { + config?: unknown; + appSecret?: unknown; +} + @Injectable() export class IntegrationConfigService { private readonly logger = new Logger(IntegrationConfigService.name); @@ -22,6 +28,18 @@ export class IntegrationConfigService { private readonly detailRepo: Repository, ) {} + /** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */ + private stringify(value: unknown): string { + return String(value); + } + + /** 解析 content JSON 并取 config 段(无 config 时回退整个对象)。 */ + private parseStoredConfig(content: string): Record { + const parsed = JSON.parse(content) as StoredConfigShape; + const rawCfg = parsed.config || parsed; + return rawCfg && typeof rawCfg === 'object' ? (rawCfg as Record) : {}; + } + /** 获取或创建主配置(全局单例) */ private async ensureConfig(): Promise { let config = await this.configRepo.findOne({ where: { type: 'THIRD' } }); @@ -80,8 +98,7 @@ export class IntegrationConfigService { if (existingDetail && existingDetail.content) { if (!finalConfig.appSecret) { try { - const oldParsed = JSON.parse(existingDetail.content); - const oldCfg = oldParsed.config || oldParsed; + const oldCfg = this.parseStoredConfig(existingDetail.content); if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret; } catch { // ignore @@ -156,7 +173,7 @@ export class IntegrationConfigService { * 供同步逻辑使用:读原始(未脱敏)配置。 * 返回 { agentId, appSecret, corpId, appId? } 或 null。 */ - async getRawConfig(type: string): Promise | null> { + async getRawConfig(type: string): Promise | null> { const config = await this.ensureConfig(); const detailType = this.getDetailType(type); const detail = await this.detailRepo.findOne({ @@ -164,8 +181,7 @@ export class IntegrationConfigService { }); if (!detail || !detail.content) return null; try { - const parsed = JSON.parse(detail.content); - return parsed.config || parsed; + return this.parseStoredConfig(detail.content); } catch { return null; } @@ -192,8 +208,8 @@ export class IntegrationConfigService { ): Promise { try { if (type.toUpperCase() === 'DINGTALK') { - const appKey = String(config.agentId || ''); - const appSecret = String(config.appSecret || ''); + const appKey = this.stringify(config.agentId || ''); + const appSecret = this.stringify(config.appSecret || ''); if (!appKey || !appSecret) return null; return await this.fetchDingTalkToken(appKey, appSecret); } @@ -227,10 +243,9 @@ export class IntegrationConfigService { private parseAndMaskConfig(content: string | null): unknown { if (!content) return {}; try { - const parsed = JSON.parse(content); - const source = parsed.config || parsed; + const source = this.parseStoredConfig(content); if (!source || typeof source !== 'object' || Array.isArray(source)) return {}; - const { appSecret: _appSecret, ...masked } = source as Record; + const { appSecret: _appSecret, ...masked } = source; return masked; } catch { return {}; diff --git a/apps/server/src/integration/wecom.service.ts b/apps/server/src/integration/wecom.service.ts index bc59189..ee94223 100644 --- a/apps/server/src/integration/wecom.service.ts +++ b/apps/server/src/integration/wecom.service.ts @@ -56,7 +56,7 @@ export class WeComService { const corpSecret = process.env.WECOM_CORP_SECRET!; const url = `${WECOM_API_BASE}${WECOM_TOKEN_PATH}?corpid=${corpId}&corpsecret=${corpSecret}`; const res = await fetch(url); - const body: WeComTokenResponse = await res.json(); + const body = (await res.json()) as WeComTokenResponse; if (body.errcode !== 0) { throw new Error(`WeCom gettoken failed: ${body.errmsg} (${body.errcode})`); } @@ -72,7 +72,7 @@ export class WeComService { const all: WeComDeptListResponse['department'] = []; const url = `${WECOM_API_BASE}${WECOM_DEPARTMENT_PATH}?access_token=${token}&id=${parentId}`; const res = await fetch(url); - const body: WeComDeptListResponse = await res.json(); + const body = (await res.json()) as WeComDeptListResponse; if (body.errcode !== 0) { if (body.errcode === 60003) return all; throw new Error(`WeCom department list failed: ${body.errmsg} (${body.errcode})`); @@ -93,7 +93,7 @@ export class WeComService { ): Promise> { const url = `${WECOM_API_BASE}${WECOM_USER_PATH}?access_token=${token}&department_id=${deptId}&fetch_child=1`; const res = await fetch(url); - const body: WeComUserListResponse = await res.json(); + const body = (await res.json()) as WeComUserListResponse; if (body.errcode !== 0) { throw new Error(`WeCom user list failed: ${body.errmsg} (${body.errcode})`); } diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 1431280..63b9653 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -11,6 +11,7 @@ async function bootstrap() { app.setGlobalPrefix('api'); app.enableCors(); app.use(helmet()); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- compression 经 export= 声明,eslint 类型解析受限 app.use(compression()); await app.listen(process.env.PORT ?? 3000); diff --git a/apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts b/apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts index 715fee8..e52cb0d 100644 --- a/apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts +++ b/apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts @@ -8,10 +8,10 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class WidenImportSheetsJson1786000000000 implements MigrationInterface { async up(queryRunner: QueryRunner): Promise { if (!(await queryRunner.hasTable('import_runs'))) return; - const rows = await queryRunner.query( + const rows = (await queryRunner.query( `SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'import_runs' AND COLUMN_NAME = 'sheets_json'`, - ); + )) as Array<{ DATA_TYPE: string }>; const current = rows?.[0]?.DATA_TYPE as string | undefined; if (current && current.toLowerCase() !== 'mediumtext') { await queryRunner.query('ALTER TABLE `import_runs` MODIFY COLUMN `sheets_json` MEDIUMTEXT'); diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index 9ee2277..f16f4b9 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -37,6 +37,12 @@ import { parseOccupancyImportWorksheet, } from './occupancy-import-template'; +interface AuthenticatedRequest { + user?: { id: number; username: string }; + headers?: Record; + connection?: { remoteAddress?: string }; +} + @UseGuards(JwtAuthGuard) @Controller('occupancies') export class OccupanciesController { @@ -66,7 +72,7 @@ export class OccupanciesController { @Put('batch-restore') @RequirePermission('occupancy:delete') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { + async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestore(dto.ids); await logAudit(this.logService, req, { module: '入住管理', action: '批量恢复入住记录', detail: `IDs: ${dto.ids.join(',')}`, @@ -76,7 +82,7 @@ export class OccupanciesController { @Post('batch-check-out') @RequirePermission('occupancy:checkout') - async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: any) { + async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchCheckOut(dto); await logAudit(this.logService, req, { module: '入住管理', action: '批量退宿', detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`, @@ -86,7 +92,7 @@ export class OccupanciesController { @Post('check-in') @RequirePermission('occupancy:checkin') - async checkIn(@Body() dto: CheckInDto, @Request() req: any) { + async checkIn(@Body() dto: CheckInDto, @Request() req: AuthenticatedRequest) { const result = await this.service.checkIn(dto, req.user?.id); await logAudit(this.logService, req, { module: '入住管理', action: '办理入住', targetId: result.id, targetType: 'occupancy', detail: `学生${dto.studentId} 入住房间${dto.roomId}`, @@ -110,7 +116,7 @@ export class OccupanciesController { @Put(':id/check-out') @RequirePermission('occupancy:checkout') - async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: any) { + async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: AuthenticatedRequest) { const result = await this.service.checkOut(+id, dto); await logAudit(this.logService, req, { module: '入住管理', action: '办理退宿', targetId: +id, targetType: 'occupancy', @@ -134,7 +140,7 @@ export class OccupanciesController { @Put(':id/transfer') @RequirePermission('occupancy:transfer') - async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: any) { + async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.transferRoom(+id, dto); await logAudit(this.logService, req, { module: '入住管理', action: '调换宿舍', targetId: +id, targetType: 'occupancy', detail: `换到房间${dto.newRoomId}`, @@ -144,7 +150,7 @@ export class OccupanciesController { @Delete(':id') @RequirePermission('occupancy:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '入住管理', action: '归档入住记录', targetId: +id, targetType: 'occupancy', @@ -154,7 +160,7 @@ export class OccupanciesController { @Post('batch-delete') @RequirePermission('occupancy:delete') - async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { + async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRemove(body.ids || []); await logAudit(this.logService, req, { module: '入住管理', action: '批量归档入住记录', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -164,7 +170,7 @@ export class OccupanciesController { @Delete(':id/permanent') @RequirePermission('occupancy:purge') - async purge(@Param('id') id: string, @Request() req: any) { + async purge(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '入住管理', action: '永久删除入住记录', targetId: +id, targetType: 'occupancy', detail: '物理删除,不可恢复', @@ -174,7 +180,7 @@ export class OccupanciesController { @Post('batch-permanent-delete') @RequirePermission('occupancy:purge') - async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurge(body.ids || []); await logAudit(this.logService, req, { module: '入住管理', action: '批量永久删除入住记录', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -257,7 +263,7 @@ export class OccupanciesController { @UseInterceptors(FileInterceptor('file')) async importCheckIn( @UploadedFile() file: Express.Multer.File, - @Request() req: any, + @Request() req: AuthenticatedRequest, @Query('autoDeposit') autoDeposit?: string, @Query('depositAmount') depositAmount?: string, ) { diff --git a/apps/server/src/occupancies/occupancy-import-template.ts b/apps/server/src/occupancies/occupancy-import-template.ts index 6437e81..12a00cc 100644 --- a/apps/server/src/occupancies/occupancy-import-template.ts +++ b/apps/server/src/occupancies/occupancy-import-template.ts @@ -68,6 +68,7 @@ function cellText(cell: ExcelJS.Cell | undefined): string { if (typeof cell.value === 'object' && 'text' in cell.value) { return String(cell.value.text).trim(); } + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本/公式对象,原样保留其字符串化结果 return String(cell.value).trim(); } diff --git a/apps/server/src/occupancies/occupancy-import.service.ts b/apps/server/src/occupancies/occupancy-import.service.ts index f1f9ec8..0fe34f2 100644 --- a/apps/server/src/occupancies/occupancy-import.service.ts +++ b/apps/server/src/occupancies/occupancy-import.service.ts @@ -1,10 +1,21 @@ import { Injectable, BadRequestException } from '@nestjs/common'; -import { DataSource, IsNull } from 'typeorm'; +import { DataSource, IsNull, DeepPartial } from 'typeorm'; import { Occupancy, Room, Student, Deposit, Bed, Locker, Organization } from '../entities'; import { RoomsService } from '../rooms/rooms.service'; class ImportRowSkipped extends Error {} +type StudentUpdateFields = Pick< + Student, + | 'studentNo' + | 'idNumber' + | 'gender' + | 'ethnicity' + | 'emergencyContact' + | 'emergencyPhone' + | 'supervisor' +>; + @Injectable() export class OccupancyImportService { constructor(private dataSource: DataSource) {} @@ -87,7 +98,7 @@ export class OccupancyImportService { ); } else { // 更新已有学生的缺失信息 - const updates: any = {}; + const updates: Partial = {}; if (!student.studentNo && row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); @@ -191,7 +202,7 @@ export class OccupancyImportService { } // 6. 创建入住记录 - const occData: any = { + const occData: DeepPartial = { studentId: student.id, roomId: room.id, checkInDate, @@ -258,11 +269,11 @@ export class OccupancyImportService { imported++; depositsCreated += result.depositsCreated; - } catch (e: any) { + } catch (e: unknown) { errors.push( e instanceof ImportRowSkipped ? e.message - : `第${rowNum}行: ${row.name} 导入失败 - ${e.message}`, + : `第${rowNum}行: ${row.name} 导入失败 - ${e instanceof Error ? e.message : String(e)}`, ); skipped++; } diff --git a/apps/server/src/operation-logs/operation-logs.controller.ts b/apps/server/src/operation-logs/operation-logs.controller.ts index 491ad0d..5541cda 100644 --- a/apps/server/src/operation-logs/operation-logs.controller.ts +++ b/apps/server/src/operation-logs/operation-logs.controller.ts @@ -4,6 +4,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { extractRequestInfo } from '../common/request-utils'; import { CreateAuditLogDto, QueryOperationLogsDto } from './dto/operation-log.dto'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + headers?: Record; + connection?: { remoteAddress?: string }; +} @UseGuards(JwtAuthGuard) @Controller('operation-logs') @@ -21,7 +28,7 @@ export class OperationLogsController { @RequirePermission('log:create') async createAuditLog( @Body() body: CreateAuditLogDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); return this.service.log({ diff --git a/apps/server/src/organizations/organizations.controller.ts b/apps/server/src/organizations/organizations.controller.ts index f260c48..a5cee23 100644 --- a/apps/server/src/organizations/organizations.controller.ts +++ b/apps/server/src/organizations/organizations.controller.ts @@ -17,6 +17,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; +import type { AuthenticatedUser } from '../authorization'; + +interface AuthenticatedRequest { + user: AuthenticatedUser; + headers?: Record; + connection?: { remoteAddress?: string }; +} @UseGuards(JwtAuthGuard) @Controller('organizations') @@ -49,7 +56,7 @@ export class OrganizationsController { @Post() @RequirePermission('organization:create') - async create(@Body() dto: CreateOrganizationDto, @Request() req: any) { + async create(@Body() dto: CreateOrganizationDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto); await this.logService.log({ @@ -68,7 +75,7 @@ export class OrganizationsController { @Put(':id') @RequirePermission('organization:edit') - async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOrganizationDto, @Request() req: any) { + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOrganizationDto, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.update(+id, dto); await this.logService.log({ @@ -87,7 +94,7 @@ export class OrganizationsController { @Delete(':id') @RequirePermission('organization:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.remove(+id); await this.logService.log({ @@ -105,7 +112,7 @@ export class OrganizationsController { @Delete(':id/permanent') @RequirePermission('organization:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.purge(+id); await this.logService.log({ diff --git a/apps/server/src/rbac/rbac.controller.ts b/apps/server/src/rbac/rbac.controller.ts index 68d1c31..c62e5b0 100644 --- a/apps/server/src/rbac/rbac.controller.ts +++ b/apps/server/src/rbac/rbac.controller.ts @@ -26,6 +26,13 @@ import { RequirePermission } from '../auth/decorators/permission.decorator'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { logAudit } from '../common/with-audit-log'; +/** JwtAuthGuard 保证 req.user 存在(类级 @UseGuards) */ +interface AuthenticatedRequest { + user: { id: number; username: string }; + headers?: Record; + connection?: { remoteAddress?: string }; +} + @UseGuards(JwtAuthGuard) @Controller('rbac') export class RbacController { @@ -48,7 +55,7 @@ export class RbacController { @Post('roles') @RequirePermission('role:create') - async createRole(@Body() dto: CreateRoleDto, @Request() req: any) { + async createRole(@Body() dto: CreateRoleDto, @Request() req: AuthenticatedRequest) { const result = await this.rbacService.createRole(dto); await logAudit(this.logService, req, { module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`, @@ -58,29 +65,29 @@ export class RbacController { @Put('roles/:id') @RequirePermission('role:edit') - async updateRole(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoleDto, @Request() req: any) { + async updateRole(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoleDto, @Request() req: AuthenticatedRequest) { try { const result = await this.rbacService.updateRole(+id, dto); await logAudit(this.logService, req, { module: 'RBAC', action: '编辑角色', targetId: +id, targetType: 'role', detail: JSON.stringify(dto), }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @Delete('roles/:id') @RequirePermission('role:delete') - async deleteRole(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async deleteRole(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { try { const result = await this.rbacService.deleteRole(+id); await logAudit(this.logService, req, { module: 'RBAC', action: '停用角色', targetId: +id, targetType: 'role', }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @@ -105,43 +112,43 @@ export class RbacController { @Post('users') @RequirePermission('user:create') - async createUser(@Body() dto: CreateUserDto, @Request() req: any) { + async createUser(@Body() dto: CreateUserDto, @Request() req: AuthenticatedRequest) { try { const result = await this.rbacService.createUser(dto); await logAudit(this.logService, req, { module: '账号', action: '创建账号', detail: `用户名: ${dto.username}`, }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @Put('users/:id') @RequirePermission('user:edit') - async updateUser(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto, @Request() req: any) { + async updateUser(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto, @Request() req: AuthenticatedRequest) { try { const result = await this.rbacService.updateUser(+id, dto); await logAudit(this.logService, req, { module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(dto), }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @Put('users/:id/password') @RequirePermission('user:reset-password') - async resetPassword(@Param('id', ParseIntPipe) id: number, @Body() dto: ResetPasswordDto, @Request() req: any) { + async resetPassword(@Param('id', ParseIntPipe) id: number, @Body() dto: ResetPasswordDto, @Request() req: AuthenticatedRequest) { try { const result = await this.rbacService.resetPassword(+id, dto.password); await logAudit(this.logService, req, { module: '账号', action: '重置密码', targetId: +id, targetType: 'user', }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @@ -169,9 +176,9 @@ export class RbacController { @Delete('users/:id/permanent') @RequirePermission('user:purge') - async purgeUser(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purgeUser(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { try { - const result = await this.rbacService.purgeUser(+id, req.user?.id); + const result = await this.rbacService.purgeUser(+id, req.user.id); await logAudit(this.logService, req, { module: '账号', action: '永久删除用户', targetId: +id, targetType: 'user', detail: '物理删除,不可恢复', }); @@ -215,7 +222,7 @@ export class RbacController { async updateUserProfile( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateProfileDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { try { const result = await this.rbacService.updateUserProfile(+id, dto); @@ -223,15 +230,15 @@ export class RbacController { module: '账号', action: '更新资料', targetId: +id, targetType: 'user', }); return result; - } catch (e: any) { - throw new BadRequestException(e.message); + } catch (e: unknown) { + throw new BadRequestException((e as { message?: string })?.message); } } @Get('teacher-workspace') @RequirePermission('teacher-workspace:view') - async getTeacherWorkspace(@Request() req: any) { - return this.rbacService.getTeacherWorkspace(req.user?.id); + async getTeacherWorkspace(@Request() req: AuthenticatedRequest) { + return this.rbacService.getTeacherWorkspace(req.user.id); } @Get('teachers') diff --git a/apps/server/src/rooms/room-query.service.ts b/apps/server/src/rooms/room-query.service.ts index 4e00862..92f3ba1 100644 --- a/apps/server/src/rooms/room-query.service.ts +++ b/apps/server/src/rooms/room-query.service.ts @@ -8,6 +8,41 @@ import { RoomInspectionsService } from './room-inspections.service'; import { occupancyWhereOnDate } from './room-occupancy-date'; import { parseRoomNumber } from './room-number'; +/** getRawMany 原始行:驱动可能返回 string 或 number,故标量字段用联合类型 */ +interface RoomSearchRawRow { + room_id: string | number; + room_room_number: string; + room_building: string | null; + room_floor: string | number | null; + room_capacity: string | number; + room_room_type: string | null; + room_status: string; +} + +interface RoomOccupancySummaryRawRow { + roomId: string | number; + roomNumber: string; + occupied: string | number; + capacity: string | number; +} + +/** getRoomVisual 中按宿舍分组的入住记录展示字段 */ +interface RoomVisualOccupant { + studentId: number; + occupancyId: number; + studentName: string; + bedId: number | null; + bedNumber: string | null; + checkInDate: string; + billingStartDate: string; + days: number; + organization: string | null; + supervisor: string | null; + organizationId: number | null; + organizationName: string | null; + organizationColor: string | null; +} + @Injectable() export class RoomQueryService { constructor( @@ -40,7 +75,7 @@ export class RoomQueryService { ]) .orderBy('room.roomNumber', 'ASC') .limit(Math.max(1, Math.min(query.limit ?? 20, 50))) - .getRawMany(); + .getRawMany(); return rows.map((row) => ({ id: Number(row.room_id), roomNumber: String(row.room_room_number), @@ -68,7 +103,7 @@ export class RoomQueryService { .groupBy('room.id') .orderBy('room.roomNumber', 'ASC') .limit(Math.max(1, Math.min(query.limit ?? 20, 50))) - .getRawMany(); + .getRawMany(); return rows.map((row) => ({ roomId: Number(row.roomId), roomNumber: String(row.roomNumber), @@ -96,7 +131,7 @@ export class RoomQueryService { }); // 按roomId分组入住记录 - const occMap = new Map(); + const occMap = new Map(); // days(已住天数)相对目标日期计算,而非固定今天,历史视图才准确。 const refTime = new Date(targetDate).getTime(); for (const occ of occupancies) { @@ -153,18 +188,18 @@ export class RoomQueryService { const inspectionByOccupancyId = new Map( (inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]), ); - const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))]; + const orgs = [...new Set(occ.map((o) => o.organization).filter(Boolean))]; let orgLabel: string | null = null; if (orgs.length > 0 && occ.length > 0) { - const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]); + const allSameOrg = occ.every((o) => o.organization && o.organization === orgs[0]); orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`; } const organizationColors = [ - ...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)), + ...new Set(occ.map((o) => o.organizationColor).filter(Boolean)), ]; const organizationColor: string | null = organizationColors.length === 1 ? organizationColors[0] : null; - const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))]; + const organizationIds = [...new Set(occ.map((o) => o.organizationId).filter(Boolean))]; return { id: room.id, roomNumber: room.roomNumber, diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index a7db9a8..dde872c 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -31,6 +31,21 @@ import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; import * as ExcelJS from 'exceljs'; +interface AuthenticatedRequest { + user?: { id: number; username: string }; + headers?: Record; + connection?: { remoteAddress?: string }; +} + +/** + * exceljs 单元格标量文本。保持原 `String(value || '')` 语义。 + * CellValue 联合类型含富文本/超链接对象,实际导入数据均为标量,故在辅助函数内局部豁免。 + */ +function cellValueText(value: unknown): string { + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本对象,实际导入数据为标量 + return String(value || ''); +} + @UseGuards(JwtAuthGuard) @Controller('rooms') export class RoomsController { @@ -64,7 +79,7 @@ export class RoomsController { @Put('batch-restore') @RequirePermission('room:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { + async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestore(dto.ids); await logAudit(this.logService, req, { module: '宿舍', action: '批量恢复宿舍', detail: `IDs: ${dto.ids.join(',')}`, @@ -78,7 +93,7 @@ export class RoomsController { @Param('roomId') roomId: string, @Param('date') date: string, @Body() dto: UpdateRoomInspectionDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.inspectionsService.submit( +roomId, @@ -269,7 +284,7 @@ export class RoomsController { @Post() @RequirePermission('room:create') - async create(@Body() dto: CreateRoomDto, @Request() req: any) { + async create(@Body() dto: CreateRoomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto); await logAudit(this.logService, req, { module: '宿舍', action: '添加宿舍', detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}人`, @@ -279,7 +294,7 @@ export class RoomsController { @Put(':id') @RequirePermission('room:edit') - async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: any) { + async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: AuthenticatedRequest) { const result = await this.service.update(+id, dto); await logAudit(this.logService, req, { module: '宿舍', action: '编辑宿舍', targetId: +id, targetType: 'room', detail: JSON.stringify(dto), @@ -289,7 +304,7 @@ export class RoomsController { @Delete(':id') @RequirePermission('room:delete') - async remove(@Param('id') id: string, @Request() req: any) { + async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(+id); await logAudit(this.logService, req, { module: '宿舍', action: '归档宿舍', targetId: +id, targetType: 'room', @@ -299,7 +314,7 @@ export class RoomsController { @Post('batch-delete') @RequirePermission('room:delete') - async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { + async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRemove(body.ids || []); await logAudit(this.logService, req, { module: '宿舍', action: '批量归档宿舍', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -309,7 +324,7 @@ export class RoomsController { @Delete(':id/permanent') @RequirePermission('room:purge') - async purge(@Param('id') id: string, @Request() req: any) { + async purge(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(+id); await logAudit(this.logService, req, { module: '宿舍', action: '永久删除宿舍', targetId: +id, targetType: 'room', detail: '物理删除,不可恢复', @@ -319,7 +334,7 @@ export class RoomsController { @Post('batch-permanent-delete') @RequirePermission('room:purge') - async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurge(body.ids || []); await logAudit(this.logService, req, { module: '宿舍', action: '批量永久删除宿舍', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -329,7 +344,7 @@ export class RoomsController { @Put(':id/restore') @RequirePermission('room:edit') - async restore(@Param('id') id: string, @Request() req: any) { + async restore(@Param('id') id: string, @Request() req: AuthenticatedRequest) { const result = await this.service.restore(+id); await logAudit(this.logService, req, { module: '宿舍', action: '恢复宿舍', targetId: +id, targetType: 'room', @@ -340,7 +355,7 @@ export class RoomsController { @Post('import') @RequirePermission('room:create') @UseInterceptors(FileInterceptor('file')) - async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); @@ -356,7 +371,7 @@ export class RoomsController { }[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; - const rentalCategoryRaw = String(row.getCell(6).value || '') + const rentalCategoryRaw = cellValueText(row.getCell(6).value) .trim() .toLowerCase(); const rentalCategory = @@ -366,11 +381,11 @@ export class RoomsController { const monthlyRateRaw = Number(row.getCell(7).value); const monthlyRate = isNaN(monthlyRateRaw) ? undefined : monthlyRateRaw; rows.push({ - roomNumber: String(row.getCell(1).value || ''), - building: String(row.getCell(2).value || '') || undefined, + roomNumber: cellValueText(row.getCell(1).value), + building: cellValueText(row.getCell(2).value) || undefined, floor: (n => Number.isNaN(n) ? undefined : n)(Number(row.getCell(3).value)), capacity: Number(row.getCell(4).value) || 4, - roomType: String(row.getCell(5).value || '').trim() || undefined, + roomType: cellValueText(row.getCell(5).value).trim() || undefined, rentalCategory, monthlyRate, }); diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index d0fd654..5232bbd 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, Repository, IsNull, Not, In } from 'typeorm'; +import { DataSource, Repository, IsNull, Not, In, FindOptionsWhere } from 'typeorm'; import { Room } from '../entities/room.entity'; import { Occupancy } from '../entities/occupancy.entity'; @@ -55,7 +55,7 @@ export class RoomsService { } async findAll(query?: { building?: string; includeArchived?: boolean }) { - const where: any = {}; + const where: FindOptionsWhere = {}; if (query?.building) where.building = query.building; if (!query?.includeArchived) where.status = Not('archived'); return this.repo.find({ where, order: { roomNumber: 'ASC' } }); @@ -78,10 +78,10 @@ export class RoomsService { } async getRoomOverview(query?: { includeArchived?: boolean }) { - const where: any = {}; + const where: FindOptionsWhere = {}; if (!query?.includeArchived) where.status = Not('archived'); const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } }); - const result: any[] = []; + const result: Array = []; for (const room of rooms) { const count = await this.occRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() }, diff --git a/apps/server/src/schedules/schedule-queries.service.ts b/apps/server/src/schedules/schedule-queries.service.ts index d146f11..c835636 100644 --- a/apps/server/src/schedules/schedule-queries.service.ts +++ b/apps/server/src/schedules/schedule-queries.service.ts @@ -6,6 +6,11 @@ import type { WeeklyViewQueryDto } from './dto/schedule.dto'; const ACTIVE_SCHEDULE_STATUS = 'active'; +/** String() 包装:避免 raw 行值(unknown 收窄为对象类型)触发 no-base-to-string。 */ +function stringify(value: unknown): string { + return String(value); +} + @Injectable() export class ScheduleQueriesService { constructor( @@ -106,18 +111,18 @@ export class ScheduleQueriesService { return rows.map((row) => ({ id: Number(row.cs_id), classId: row.cs_class_id == null ? null : Number(row.cs_class_id), - className: row.class_name == null ? null : String(row.class_name), + className: row.class_name == null ? null : stringify(row.class_name), classroomId: Number(row.cs_classroom_id), - classroomName: row.classroom_name == null ? null : String(row.classroom_name), + classroomName: row.classroom_name == null ? null : stringify(row.classroom_name), weekDay: Number(row.cs_week_day), - startTime: String(row.cs_start_time), - endTime: String(row.cs_end_time), - subject: String(row.cs_subject), - teacherName: row.teacher_name == null ? null : String(row.teacher_name), - startDate: String(row.cs_start_date), - endDate: String(row.cs_end_date), - scheduleType: String(row.cs_schedule_type), - status: String(row.cs_status), + startTime: stringify(row.cs_start_time), + endTime: stringify(row.cs_end_time), + subject: stringify(row.cs_subject), + teacherName: row.teacher_name == null ? null : stringify(row.teacher_name), + startDate: stringify(row.cs_start_date), + endDate: stringify(row.cs_end_date), + scheduleType: stringify(row.cs_schedule_type), + status: stringify(row.cs_status), })); } diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index 276e1c8..e1ec290 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -97,7 +97,7 @@ export class QueryStudentDto { status?: string; @IsOptional() - @Transform(({ value }) => { + @Transform(({ value }: { value: unknown }) => { if (typeof value === 'boolean') return value; if (value === 'true' || value === '1') return true; if (value === 'false' || value === '0') return false; diff --git a/apps/server/src/students/student-import.ts b/apps/server/src/students/student-import.ts index 0962771..eee277d 100644 --- a/apps/server/src/students/student-import.ts +++ b/apps/server/src/students/student-import.ts @@ -166,6 +166,8 @@ function cellToText(cell: ExcelJS.Cell): string { const value = getCellPrimitiveValue(cell); if (value === null || value === undefined) return ''; if (value instanceof Date) return formatDate(value); + // 对象值(错误单元格/共享公式等)保留既有 String() 行为,不做类型收窄 + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- 对象值保留既有 '[object Object]' 输出 return String(value).trim(); } @@ -186,7 +188,10 @@ function parseDateText(cell: ExcelJS.Cell): string | undefined { const value = getCellPrimitiveValue(cell); if (value instanceof Date) return formatDate(value); if (typeof value === 'number') return excelSerialToDate(value); - const text = value === null || value === undefined ? '' : String(value).trim(); + if (value === null || value === undefined) return undefined; + // 对象值(错误单元格等)保留既有 String() 行为 + // eslint-disable-next-line @typescript-eslint/no-base-to-string -- 对象值保留既有 String() 语义 + const text = String(value).trim(); if (!text) return undefined; const normalized = text.replace(/[/.]/g, '-'); const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u); diff --git a/apps/server/src/students/students.agent.service.ts b/apps/server/src/students/students.agent.service.ts index 34f446b..cf829ef 100644 --- a/apps/server/src/students/students.agent.service.ts +++ b/apps/server/src/students/students.agent.service.ts @@ -5,6 +5,17 @@ import { Student } from '../entities/student.entity'; import { ClassStudent } from '../entities/class-student.entity'; import type { StudentAccessScope } from './student-access-scope'; +/** getRawMany/getRawOne 原始行(select 别名即原始键名;可空列按 NULL 处理) */ +interface AgentStudentRawRow { + student_id: number; + student_name: string; + student_student_no: string | null; + student_gender: string | null; + student_status: string; + student_organization_id: number; + organization_name: string | null; +} + @Injectable() export class StudentsAgentService { /** @@ -84,13 +95,13 @@ export class StudentsAgentService { qb.orderBy('student.createdAt', 'DESC').take(limit); - const rows: Record[] = await qb.getRawMany(); + const rows = await qb.getRawMany(); if (rows.length === 0) return []; // Second bounded query: classIds only for the returned student ids. // For teacher scope, the class filter MUST be re-applied so the // teacher only sees classIds they are assigned to. - const studentIds = rows.map((r) => r.student_id as number); + const studentIds = rows.map((r) => r.student_id); const csQb = this.classStudentRepo .createQueryBuilder('cs') .select(['cs.studentId', 'cs.classId']) @@ -104,24 +115,24 @@ export class StudentsAgentService { ); } - const classRows = await csQb.getRawMany(); + const classRows = await csQb.getRawMany<{ cs_student_id: number; cs_class_id: number }>(); const classMap = new Map(); - for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) { + for (const cr of classRows) { const sid = cr.cs_student_id; if (!classMap.has(sid)) classMap.set(sid, []); classMap.get(sid)!.push(cr.cs_class_id); } return rows.map((r) => ({ - id: r.student_id as number, - name: r.student_name as string, - studentNo: (r.student_student_no as string) ?? '', - gender: (r.student_gender as string) ?? '', - status: r.student_status as string, - organizationId: r.student_organization_id as number, - organizationName: (r.organization_name as string) ?? '', - classIds: classMap.get(r.student_id as number) ?? [], + id: r.student_id, + name: r.student_name, + studentNo: r.student_student_no ?? '', + gender: r.student_gender ?? '', + status: r.student_status, + organizationId: r.student_organization_id, + organizationName: r.organization_name ?? '', + classIds: classMap.get(r.student_id) ?? [], })); } @@ -158,7 +169,7 @@ export class StudentsAgentService { this.applyStudentScope(qb, scope); - const row = await qb.getRawOne(); + const row = await qb.getRawOne(); if (!row) return null; // For teacher scope, re-apply class filter so teacher only sees @@ -176,17 +187,17 @@ export class StudentsAgentService { ); } - const classRows = await csQb.getRawMany(); + const classRows = await csQb.getRawMany<{ cs_class_id: number }>(); return { - id: row.student_id as number, - name: row.student_name as string, - studentNo: (row.student_student_no as string) ?? '', - gender: (row.student_gender as string) ?? '', - status: row.student_status as string, - organizationId: row.student_organization_id as number, - organizationName: (row.organization_name as string) ?? '', - classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id), + id: row.student_id, + name: row.student_name, + studentNo: row.student_student_no ?? '', + gender: row.student_gender ?? '', + status: row.student_status, + organizationId: row.student_organization_id, + organizationName: row.organization_name ?? '', + classIds: classRows.map((cr) => cr.cs_class_id), }; } diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 0920f64..571f5b6 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -91,7 +91,7 @@ export class StudentsController { @Get('export') @RequirePermission('student:export') - async exportExcel(@Query() query: QueryStudentDto, @Res() res?: Response, @Request() req?: any) { + async exportExcel(@Query() query: QueryStudentDto, @Res() res: Response, @Request() req: AuthenticatedRequest) { const classIds = await this.service.getAccessibleClassIds( req.user.id, this.canManageAllStudents(req), @@ -137,13 +137,13 @@ export class StudentsController { await logAudit(this.logService, req, { module: '学生管理', action: '导出学生', detail: `导出 ${students.length} 名学生`, }); - res!.setHeader( + res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ); - res!.setHeader('Content-Disposition', 'attachment; filename=students.xlsx'); - await workbook.xlsx.write(res!); - res!.end(); + res.setHeader('Content-Disposition', 'attachment; filename=students.xlsx'); + await workbook.xlsx.write(res); + res.end(); } @Get('template') @@ -167,7 +167,7 @@ export class StudentsController { @Post() @RequirePermission('student:create') - async create(@Body() dto: CreateStudentDto, @Request() req: any) { + async create(@Body() dto: CreateStudentDto, @Request() req: AuthenticatedRequest) { const result = await this.service.create(dto); await logAudit(this.logService, req, { module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`, @@ -178,7 +178,7 @@ export class StudentsController { @Put('batch-restore') @RequirePermission('student:edit') @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) - async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) { + async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRestore(dto.ids); await logAudit(this.logService, req, { module: '学生管理', action: '批量恢复学生', detail: `IDs: ${dto.ids.join(',')}`, @@ -191,7 +191,7 @@ export class StudentsController { async update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, - @Request() req: any, + @Request() req: AuthenticatedRequest, ) { const result = await this.service.update(id, dto); await logAudit(this.logService, req, { @@ -202,7 +202,7 @@ export class StudentsController { @Delete(':id') @RequirePermission('student:delete') - async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.remove(id); await logAudit(this.logService, req, { module: '学生管理', action: '归档学生', targetId: id, targetType: 'student', @@ -212,7 +212,7 @@ export class StudentsController { @Post('batch-delete') @RequirePermission('student:delete') - async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) { + async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchRemove(body.ids || []); await logAudit(this.logService, req, { module: '学生管理', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -222,7 +222,7 @@ export class StudentsController { @Delete(':id/permanent') @RequirePermission('student:purge') - async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.purge(id); await logAudit(this.logService, req, { module: '学生管理', action: '永久删除学生', targetId: id, targetType: 'student', detail: '物理删除,不可恢复', @@ -232,7 +232,7 @@ export class StudentsController { @Post('batch-permanent-delete') @RequirePermission('student:purge') - async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) { + async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) { const result = await this.service.batchPurge(body.ids || []); await logAudit(this.logService, req, { module: '学生管理', action: '批量永久删除学生', detail: `IDs: ${(body.ids || []).join(',')}`, @@ -242,7 +242,7 @@ export class StudentsController { @Put(':id/restore') @RequirePermission('student:edit') - async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) { + async restore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) { const result = await this.service.restore(id); await logAudit(this.logService, req, { module: '学生管理', action: '恢复学生', targetId: id, targetType: 'student', @@ -253,7 +253,7 @@ export class StudentsController { @Post('import') @RequirePermission('student:import') @UseInterceptors(FileInterceptor('file')) - async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); @@ -278,7 +278,7 @@ export class StudentsController { @Post('import-match') @RequirePermission('student:import') @UseInterceptors(FileInterceptor('file')) - async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) { + async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); const importData = parseStudentImportWorkbook(workbook); diff --git a/apps/server/src/sync/jinshuju-rules.ts b/apps/server/src/sync/jinshuju-rules.ts index 6248b3f..5b35fae 100644 --- a/apps/server/src/sync/jinshuju-rules.ts +++ b/apps/server/src/sync/jinshuju-rules.ts @@ -28,7 +28,10 @@ export function validateMatchRule(formToken: string, mappings: JinshujuFieldMapp 'emergencyContact', 'emergencyPhone', ]); - for (const [studentField, fieldKey] of Object.entries(mappings)) { + // Object.entries 对无索引签名的接口会回退为 any,这里显式标注条目类型 + for (const [studentField, fieldKey] of Object.entries(mappings) as Array< + [string, string | undefined] + >) { if (!allowedStudentFields.has(studentField)) { throw new ConflictException(`不允许映射学生字段:${studentField}`); } diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index 5332520..b953de6 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -189,7 +189,7 @@ export class SyncService { let matched = 0; let created = 0; await this.dataSource.transaction(async (manager) => { - const orgs = await manager.query( + const orgs = await manager.query>( 'SELECT id FROM organizations WHERE is_host = 1 AND status = ? LIMIT 1', ['active'], ); @@ -199,9 +199,23 @@ export class SyncService { const decision = decisionMap.get(serial); if (!decision || decision.action === 'skip') continue; - const mappedValues = Object.fromEntries( - Object.entries(map) - .map(([studentField, fieldKey]) => [studentField, extractField(entry, fieldKey)]) + // 匹配规则只允许映射以下字符串字段(validateMatchRule 白名单)。 + const mappedValues: Partial< + Pick< + Student, + | 'name' + | 'studentNo' + | 'phone' + | 'idNumber' + | 'gender' + | 'ethnicity' + | 'emergencyContact' + | 'emergencyPhone' + > + > = Object.fromEntries( + // Object.entries 对无索引签名的接口会回退为 any,这里显式标注条目类型 + (Object.entries(map) as Array<[string, string | undefined]>) + .map(([studentField, fieldKey]) => [studentField, extractField(entry, fieldKey)] as const) .filter(([, value]) => value), ); diff --git a/apps/server/src/wallets/wallets.controller.ts b/apps/server/src/wallets/wallets.controller.ts index 6bf4304..e9027f9 100644 --- a/apps/server/src/wallets/wallets.controller.ts +++ b/apps/server/src/wallets/wallets.controller.ts @@ -3,9 +3,16 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; +import type { AuthenticatedUser } from '../authorization'; import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; import { WalletsService } from './wallets.service'; +interface AuthenticatedRequest { + user: AuthenticatedUser; + ip?: string; + headers?: Record; +} + @UseGuards(JwtAuthGuard) @Controller('wallets') export class WalletsController { @@ -35,7 +42,7 @@ export class WalletsController { @Post('change-balance') @RequirePermission('wallet:edit') - async changeBalance(@Body() dto: ChangeWalletBalanceDto, @Request() req: any) { + async changeBalance(@Body() dto: ChangeWalletBalanceDto, @Request() req: AuthenticatedRequest) { const result = await this.service.changeBalance(dto, req.user?.id); const { ipAddress, userAgent } = extractRequestInfo(req); await this.logService.log({ @@ -55,7 +62,7 @@ export class WalletsController { @Post('batch-change-balance') @RequirePermission('wallet:edit') - async batchChangeBalance(@Body() dto: BatchChangeWalletBalanceDto, @Request() req: any) { + async batchChangeBalance(@Body() dto: BatchChangeWalletBalanceDto, @Request() req: AuthenticatedRequest) { const result = await this.service.batchChangeBalance(dto, req.user?.id); const { ipAddress, userAgent } = extractRequestInfo(req); await this.logService.log({