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:
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(' / ')} 之一` };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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} 条更新格式无效` };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '物理删除,不可恢复',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -22,7 +22,7 @@ const mappingTransformer = {
|
||||
},
|
||||
from(value: string | null): JinshujuFieldMapping {
|
||||
if (!value) return {};
|
||||
return JSON.parse(value);
|
||||
return JSON.parse(value) as JinshujuFieldMapping;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user