diff --git a/apps/server/src/agent-tools/tools/create-student.tool.ts b/apps/server/src/agent-tools/tools/create-student.tool.ts index 6b48716..7bfdb6e 100644 --- a/apps/server/src/agent-tools/tools/create-student.tool.ts +++ b/apps/server/src/agent-tools/tools/create-student.tool.ts @@ -3,6 +3,7 @@ import { DataSource } from 'typeorm'; import { Organization } from '../../entities/organization.entity'; import { StudentsService } from '../../students/students.service'; import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types'; +import { stringify } from '../../common/stringify'; /** Whitelisted input shape for create_student. */ interface CreateStudentInput { @@ -28,11 +29,6 @@ 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. * diff --git a/apps/server/src/ai-chat/ai-excel-reader.service.ts b/apps/server/src/ai-chat/ai-excel-reader.service.ts index 83d1d98..7bd996a 100644 --- a/apps/server/src/ai-chat/ai-excel-reader.service.ts +++ b/apps/server/src/ai-chat/ai-excel-reader.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import ExcelJS from 'exceljs'; import { readXlsxSheetsFallback } from '../imports/imports.workbook-fallback'; +import { bufferToArrayBuffer } from '../common/buffer'; export interface ExcelSheetInfo { name: string; @@ -86,7 +87,7 @@ export class AiExcelReaderService { private async loadWithExcelJs(buffer: Buffer): Promise { const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(buffer)); const sheets: ExcelSheetRows[] = []; workbook.eachSheet((sheet) => { const rows: string[][] = []; diff --git a/apps/server/src/classrooms/classrooms.controller.ts b/apps/server/src/classrooms/classrooms.controller.ts index 347b8b7..3dbfa05 100644 --- a/apps/server/src/classrooms/classrooms.controller.ts +++ b/apps/server/src/classrooms/classrooms.controller.ts @@ -24,6 +24,7 @@ import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import * as ExcelJS from 'exceljs'; import { CLASSROOM_TEMPLATE_COLUMNS } from './classroom-template'; +import { bufferToArrayBuffer } from '../common/buffer'; interface AuthenticatedRequest { user?: { id: number; username: string }; @@ -173,7 +174,7 @@ export class ClassroomsController { 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); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows: { name: string; diff --git a/apps/server/src/common/buffer.ts b/apps/server/src/common/buffer.ts new file mode 100644 index 0000000..c731caf --- /dev/null +++ b/apps/server/src/common/buffer.ts @@ -0,0 +1,17 @@ +/** + * 将 Node Buffer 安全转换为 ArrayBuffer。 + * + * ExcelJS 的 xlsx.load 类型只接受 Buffer | ArrayBuffer,而 Node 20+ 的 + * Buffer 已是泛型 Buffer,与 exceljs 的旧类型签名不兼容, + * 直接传参会报 TS2345。此处取底层 ArrayBuffer 的精确切片 + * (含 byteOffset/byteLength),既避免 Buffer 来自池切片时携带无关字节, + * 也把类型边界收敛到单一实现。 + */ +export function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength, + ) as ArrayBuffer; +} + +export default bufferToArrayBuffer; diff --git a/apps/server/src/common/stringify.ts b/apps/server/src/common/stringify.ts new file mode 100644 index 0000000..6ec21c6 --- /dev/null +++ b/apps/server/src/common/stringify.ts @@ -0,0 +1,13 @@ +/** + * 安全字符串化 unknown。 + * + * `@typescript-eslint/no-base-to-string` 规则在调用点会把 unknown 经 + * `== null` / `||` / `??` 收窄为 `{}`(对象类型)后仍标记 String(value); + * 而函数参数位置不受调用点收窄影响。此助手统一处理该场景, + * 避免各模块重复定义同名工具。 + */ +export function stringify(value: unknown): string { + return String(value); +} + +export default stringify; diff --git a/apps/server/src/database/database-migrations.backfill.ts b/apps/server/src/database/database-migrations.backfill.ts index 744d193..078404d 100644 --- a/apps/server/src/database/database-migrations.backfill.ts +++ b/apps/server/src/database/database-migrations.backfill.ts @@ -2,17 +2,13 @@ import { Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { uuidV7 } from '../common/uuid-v7'; import { withQueryRunner } from './database-migrations.runner'; +import { stringify } from '../common/stringify'; /** 迁移脚本中用到的 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 { diff --git a/apps/server/src/expenses/expenses.controller.ts b/apps/server/src/expenses/expenses.controller.ts index 170992c..1a9bfce 100644 --- a/apps/server/src/expenses/expenses.controller.ts +++ b/apps/server/src/expenses/expenses.controller.ts @@ -37,6 +37,7 @@ import { BatchIdsDto } from '../common/batch-ids.dto'; import type { AuthenticatedUser } from '../authorization'; import { PersonalExpense } from '../entities/personal-expense.entity'; import * as ExcelJS from 'exceljs'; +import { bufferToArrayBuffer } from '../common/buffer'; interface AuthenticatedRequest { user: AuthenticatedUser; @@ -354,7 +355,7 @@ export class ExpensesController { @UseInterceptors(FileInterceptor('file')) 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); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows: UtilityImportRow[] = []; ws.eachRow((row, idx) => { @@ -419,7 +420,7 @@ export class ExpensesController { @UseInterceptors(FileInterceptor('file')) 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); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows: PersonalImportRow[] = []; ws.eachRow((row, idx) => { diff --git a/apps/server/src/imports/imports.workbook.ts b/apps/server/src/imports/imports.workbook.ts index e4f248e..4df5103 100644 --- a/apps/server/src/imports/imports.workbook.ts +++ b/apps/server/src/imports/imports.workbook.ts @@ -4,6 +4,7 @@ import { Readable } from 'node:stream'; import { cellValue, textValue } from './imports.helpers'; import { fallbackSheetsToImportSheets, readXlsxSheetsFallback } from './imports.workbook-fallback'; import type { CellValue } from './imports.types'; +import { bufferToArrayBuffer } from '../common/buffer'; const MAX_SHEETS = 30; const MAX_ROWS_PER_SHEET = 3000; @@ -84,7 +85,7 @@ export async function parseSheets( if (kind === 'csv') { await workbook.csv.read(Readable.from(Buffer.from(buffer))); } else { - await workbook.xlsx.load(buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(buffer)); } const sheets = extractSheets(workbook, headerRow); if (sheets.length === 0) { diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index 0c300d5..ba12be8 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -1,3 +1,4 @@ +import { stringify } from '../../common/stringify'; import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -29,10 +30,6 @@ export class IntegrationConfigService { ) {} /** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */ - private stringify(value: unknown): string { - return String(value); - } - /** 解析 content JSON 并取 config 段(无 config 时回退整个对象)。 */ private parseStoredConfig(content: string): Record { const parsed = JSON.parse(content) as StoredConfigShape; @@ -208,8 +205,8 @@ export class IntegrationConfigService { ): Promise { try { if (type.toUpperCase() === 'DINGTALK') { - const appKey = this.stringify(config.agentId || ''); - const appSecret = this.stringify(config.appSecret || ''); + const appKey = stringify(config.agentId || ''); + const appSecret = stringify(config.appSecret || ''); if (!appKey || !appSecret) return null; return await this.fetchDingTalkToken(appKey, appSecret); } diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index f16f4b9..16c9f4d 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -1,3 +1,4 @@ +import { bufferToArrayBuffer } from '../common/buffer'; import { Controller, Get, @@ -270,7 +271,7 @@ export class OccupanciesController { const { ipAddress, userAgent } = extractRequestInfo(req); if (!file?.buffer) throw new BadRequestException('请上传入住名单 Excel 文件'); const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows = parseOccupancyImportWorksheet(ws); const result = await this.service.batchImportCheckIn(rows, { diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index dde872c..9a4b599 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -30,6 +30,7 @@ import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; import * as ExcelJS from 'exceljs'; +import { bufferToArrayBuffer } from '../common/buffer'; interface AuthenticatedRequest { user?: { id: number; username: string }; @@ -358,7 +359,7 @@ export class RoomsController { 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); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const ws = workbook.worksheets[0]; const rows: { roomNumber: string; diff --git a/apps/server/src/schedules/schedule-queries.service.ts b/apps/server/src/schedules/schedule-queries.service.ts index c835636..badf87f 100644 --- a/apps/server/src/schedules/schedule-queries.service.ts +++ b/apps/server/src/schedules/schedule-queries.service.ts @@ -3,14 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ClassSchedule } from '../entities'; import type { WeeklyViewQueryDto } from './dto/schedule.dto'; +import { stringify } from '../common/stringify'; 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( diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 571f5b6..d84014b 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -40,6 +40,7 @@ import { STUDENT_EXPORT_COLUMNS, } from './student-import'; import { BatchIdsDto } from '../common/batch-ids.dto'; +import { bufferToArrayBuffer } from '../common/buffer'; interface AuthenticatedRequest { user: AuthenticatedUser; @@ -255,7 +256,7 @@ export class StudentsController { @UseInterceptors(FileInterceptor('file')) 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); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) { @@ -280,7 +281,7 @@ export class StudentsController { @UseInterceptors(FileInterceptor('file')) 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); + await workbook.xlsx.load(bufferToArrayBuffer(file.buffer)); const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs for (const row of importData.students) {