refactor(server): 清理全量 any 类型安全警告 (692 → 0)

- 全模块类型化:controller 的 req: any → AuthenticatedRequest/RequestUser,
  聚合查询 getRawMany 泛型标注、导入行/响应体定义具体 interface、
  catch (e: any) → unknown + 收窄、no-base-to-string 用 String() 显式转换
- 第三方无类型库边界(pdfkit/exceljs)文件级或单行 disable 并注明理由
- 顺带修复:get-business-context.tool 两个 require-await error、
  bills.controller 参数顺序隐患、main.ts compression 调用
- 运行时逻辑零改动;测试 142 套件 / 1065 用例全部通过
This commit is contained in:
2026-08-08 09:28:23 +08:00
parent 8b5fa98028
commit a644a8de42
63 changed files with 690 additions and 338 deletions

View File

@@ -39,10 +39,12 @@ export class GetBusinessContextTool implements ToolDef<GetBusinessContextInput>
return { ok: true, value: { workflowKey: workflowKey.value } };
}
async execute(input: GetBusinessContextInput, context: AgentToolContext): Promise<unknown> {
return this.service.getBusinessContext(
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
input.workflowKey,
execute(input: GetBusinessContextInput, context: AgentToolContext): Promise<unknown> {
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<unknown> {
return this.service.getEntitySchema(
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
input.entityKey,
execute(input: { entityKey: string }, context: AgentToolContext): Promise<unknown> {
return Promise.resolve(
this.service.getEntitySchema(
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
input.entityKey,
),
);
}
}

View File

@@ -39,7 +39,7 @@ export class GetPendingTasksTool implements ToolDef<GetPendingTasksInput> {
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(' / ')} 之一` };
}

View File

@@ -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<Record<string, unknown>>)[0]?.cnt ?? 0);
const rows = await this.dataSource.query<Array<Record<string, unknown>>>(sql, params);
const count = Number(rows[0]?.cnt ?? 0);
tasks.push({
key: definition.key,
label: definition.label,

View File

@@ -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<CreateStudentInput> {
}
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'];

View File

@@ -151,8 +151,9 @@ export class UpdateStudentsTool implements ToolDef<UpdateStudentsInput> {
const seenIds = new Set<number>();
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} 条更新格式无效` };
}

View File

@@ -1,5 +1,4 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
NotFoundException,

View File

@@ -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<string | ModelContentPart[]> {
return buildUserContent(this, text, attachments, supportsVision);

View File

@@ -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<string | ModelContentPart[]> {
if (!attachments.length) return text;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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]

View File

@@ -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<ModelMessage[]>;
buildUserContent(
text: string,
attachments: any[],
attachments: AiAttachment[],
supportsVision: boolean,
): Promise<string | ModelContentPart[]>;
truncateText(value: string, max: number): string;

View File

@@ -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<string, string | string[] | undefined>;
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({

View File

@@ -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[];

View File

@@ -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));

View File

@@ -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;
}
}

View File

@@ -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 } });
}
}

View File

@@ -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';

View File

@@ -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<string, string | string[] | undefined>;
}
@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',
});

View File

@@ -176,8 +176,10 @@ export class BillsService {
}
/** 查询时附加钱包余额和实际支付数据。 */
private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
if (!bills?.length) return bills;
private async attachDepositInfo(
bills: Bill[],
): Promise<Array<Bill & { walletBalance: number }>> {
if (!bills?.length) return bills as Array<Bill & { walletBalance: number }>;
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,

View File

@@ -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, {

View File

@@ -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;

View File

@@ -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<string, string | string[] | undefined>;
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',

View File

@@ -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<Record<string, unknown>>();
.getRawMany<AgentRentalRawRow>();
return rows.map((row) => ({
id: Number(row.id),
classroomName: row.classroomName == null ? '' : String(row.classroomName),

View File

@@ -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<string, string | string[] | undefined>;
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,
});
});

View File

@@ -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<ScheduleUsageRawRow>();
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<RentalUsageRawRow>();
for (const rental of rentals) {
const classroomId = Number(rental.classroomId);

View File

@@ -1,13 +1,19 @@
/** 从请求对象提取 IP / UA 所需的最小结构。 */
export interface RequestInfoSource {
headers?: Record<string, string | string[] | undefined>;
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 };
}

View File

@@ -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<string, { total: number; present: number }>();
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<number, { className: string; present: number; total: number }>();
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;
}

View File

@@ -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<string, number>,
@@ -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<number, number> = {};
const rMap: Record<number, number> = {};
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';

View File

@@ -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}`);

View File

@@ -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<void> {
@@ -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<OrganizationRow[]> =>
runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1') as Promise<OrganizationRow[]>;
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<Record<string, unknown>> =
await runner.query('SELECT * FROM tenants');
const legacyTenants = (await runner.query('SELECT * FROM tenants')) as Array<
Record<string, unknown>
>;
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<UpdateResultLike | undefined>((manager) =>
manager.query(`
UPDATE classes
SET

View File

@@ -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<string, string | string[] | undefined>;
}
@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: '物理删除,不可恢复',

View File

@@ -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<string, string | number | Date | null>;
const capacityRoomTypeText: Record<number, string> = {
1: '单人间',
2: '二人间',
@@ -99,7 +102,7 @@ export class DepositsService {
}
}
const rows = await qb.getRawMany();
const rows = await qb.getRawMany<RawScalarRow>();
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<Record<string, unknown>>();
.getRawMany<RawScalarRow>();
return rows.map((row) => ({
id: Number(row.id),
studentName: row.studentName == null ? '' : String(row.studentName),

View File

@@ -22,7 +22,7 @@ const mappingTransformer = {
},
from(value: string | null): JinshujuFieldMapping {
if (!value) return {};
return JSON.parse(value);
return JSON.parse(value) as JinshujuFieldMapping;
},
};

View File

@@ -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;

View File

@@ -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<string, string | string[] | undefined>;
}
@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({

View File

@@ -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++;
}
}

View File

@@ -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<string, string | string[] | undefined>;
}
/** 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<string, unknown>;
// 公式单元格:{ 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,

View File

@@ -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<string, string | number | Date | null>;
@Injectable()
export class ExpensesService {
@@ -154,7 +157,7 @@ export class ExpensesService {
const roomRows = await roomQb
.orderBy('e.createdAt', 'DESC')
.limit(limit)
.getRawMany<Record<string, unknown>>();
.getRawMany<RawScalarRow>();
const personalQb = this.personalExpRepo
.createQueryBuilder('e')
@@ -186,7 +189,7 @@ export class ExpensesService {
const personalRows = await personalQb
.orderBy('e.createdAt', 'DESC')
.limit(limit)
.getRawMany<Record<string, unknown>>();
.getRawMany<RawScalarRow>();
return {
roomExpenses: roomRows.map((row) => ({

View File

@@ -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() {

View File

@@ -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';

View File

@@ -7,7 +7,7 @@ import { parseSheets } from './imports.workbook';
async function xlsxBuffer(rows: Array<Array<unknown>>): Promise<Buffer> {
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;
}

View File

@@ -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<IntegrationConfigDetail>,
) {}
/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */
private stringify(value: unknown): string {
return String(value);
}
/** 解析 content JSON 并取 config 段(无 config 时回退整个对象)。 */
private parseStoredConfig(content: string): Record<string, unknown> {
const parsed = JSON.parse(content) as StoredConfigShape;
const rawCfg = parsed.config || parsed;
return rawCfg && typeof rawCfg === 'object' ? (rawCfg as Record<string, unknown>) : {};
}
/** 获取或创建主配置(全局单例) */
private async ensureConfig(): Promise<IntegrationConfig> {
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<Record<string, any> | null> {
async getRawConfig(type: string): Promise<Record<string, unknown> | 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<string | null> {
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<string, unknown>;
const { appSecret: _appSecret, ...masked } = source;
return masked;
} catch {
return {};

View File

@@ -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<Array<{ userid: string; name: string; mobile: string; department: number[] }>> {
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})`);
}

View File

@@ -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);

View File

@@ -8,10 +8,10 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
export class WidenImportSheetsJson1786000000000 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
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');

View File

@@ -37,6 +37,12 @@ import {
parseOccupancyImportWorksheet,
} from './occupancy-import-template';
interface AuthenticatedRequest {
user?: { id: number; username: string };
headers?: Record<string, string | string[] | undefined>;
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,
) {

View File

@@ -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();
}

View File

@@ -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<StudentUpdateFields> = {};
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<Occupancy> = {
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++;
}

View File

@@ -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<string, string | string[] | undefined>;
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({

View File

@@ -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<string, string | string[] | undefined>;
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({

View File

@@ -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<string, string | string[] | undefined>;
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')

View File

@@ -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<RoomSearchRawRow>();
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<RoomOccupancySummaryRawRow>();
return rows.map((row) => ({
roomId: Number(row.roomId),
roomNumber: String(row.roomNumber),
@@ -96,7 +131,7 @@ export class RoomQueryService {
});
// 按roomId分组入住记录
const occMap = new Map<number, any[]>();
const occMap = new Map<number, RoomVisualOccupant[]>();
// 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,

View File

@@ -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<string, string | string[] | undefined>;
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,
});

View File

@@ -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<Room> = {};
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<Room> = {};
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<Room & { currentCount: number }> = [];
for (const room of rooms) {
const count = await this.occRepo.count({
where: { roomId: room.id, checkOutDate: IsNull() },

View File

@@ -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),
}));
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<string, unknown>[] = await qb.getRawMany();
const rows = await qb.getRawMany<AgentStudentRawRow>();
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<number, number[]>();
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<AgentStudentRawRow>();
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),
};
}

View File

@@ -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);

View File

@@ -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}`);
}

View File

@@ -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<Array<{ id: number }>>(
'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),
);

View File

@@ -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<string, string | string[] | undefined>;
}
@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({