From 4c2192d85ef1d41e0e99f8c36dc399b05e5b1797 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 20 Jul 2026 12:24:51 +0800 Subject: [PATCH 1/3] feat: switch to Docker Compose + auto-migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: 移除 MySQL 容器,后端连宿主机 MySQL - main.ts: 启动时自动 runMigrations - 前端 Nginx 反代 /api/ → backend:3000 不变 --- apps/server/src/main.ts | 5 ++++- docker-compose.yml | 38 +++++++------------------------------- 2 files changed, 11 insertions(+), 32 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index b08dc0c..411dcab 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,12 +1,15 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; +import { DataSource } from 'typeorm'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); app.enableCors(); - app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + const dataSource = app.get(DataSource); + await dataSource.runMigrations(); + console.log('Migrations OK'); await app.listen(process.env.PORT ?? 3000); console.log(`Server running on http://localhost:${process.env.PORT ?? 3000}`); } diff --git a/docker-compose.yml b/docker-compose.yml index 3e85551..86ecd69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,46 +1,25 @@ version: '3.8' services: - mysql: - image: mysql:8.0 - container_name: gongxue_mysql - restart: always - environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-gongxue_2024} - MYSQL_DATABASE: dorm_billing_v2 - MYSQL_CHARSET: utf8mb4 - MYSQL_COLLATION: utf8mb4_unicode_ci - ports: - - "127.0.0.1:3306:3306" - volumes: - - mysql_data:/var/lib/mysql - - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro - command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - interval: 5s - retries: 10 - backend: build: context: . dockerfile: apps/server/Dockerfile container_name: gongxue_backend restart: always + ports: + - "127.0.0.1:3000:3000" environment: DB_TYPE: mysql - DB_HOST: mysql + DB_HOST: host.docker.internal DB_PORT: 3306 - DB_USERNAME: root - DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-gongxue_2024} - DB_DATABASE: dorm_billing_v2 + DB_USERNAME: ${MYSQL_ROOT_USERNAME:-root} + DB_PASSWORD: ${MYSQL_ROOT_PASSWORD} + DB_DATABASE: ${DB_DATABASE:-dorm_billing_v2} DB_SYNCHRONIZE: "false" JWT_SECRET: ${JWT_SECRET:-gongxue-jwt-prod-2026-k3y} JWT_EXPIRES_IN: 24h PORT: 3000 - depends_on: - mysql: - condition: service_healthy frontend: build: @@ -49,9 +28,6 @@ services: container_name: gongxue_frontend restart: always ports: - - "9527:80" + - "127.0.0.1:9527:80" depends_on: - backend - -volumes: - mysql_data: From 990c0c16e68e04ee38adef9e0cbd0c8362bf2618 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 20 Jul 2026 12:43:13 +0800 Subject: [PATCH 2/3] feat: Docker Compose deployment + auto migration on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docker-compose.yml: 移除 MySQL 容器,连宿主机 MySQL - main.ts: 启动前通过独立 DataSource 执行 runMigrations - migration-runner.ts: 独立 migration 执行器 - app.module.ts: TypeORM 配置增加 migrations - 修复 InitialSchema 中 room_expenses 和 financial_operations 重复索引 --- apps/server/src/app.module.ts | 4 ++++ apps/server/src/main.ts | 7 +++--- apps/server/src/migration-runner.ts | 24 +++++++++++++++++++ .../migrations/1784520727860-InitialSchema.ts | 5 ++-- docker-compose.yml | 1 - 5 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/migration-runner.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 3fbb83f..a09f1b6 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -50,6 +50,8 @@ import { FinancialOperation, } from './entities'; import { AuthModule } from './auth/auth.module'; +import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema'; +const allMigrations = [InitialSchema1784520727860]; import { AuthorizationModule } from './authorization'; import { RbacModule } from './rbac/rbac.module'; import { StudentsModule } from './students/students.module'; @@ -158,6 +160,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; password: config.get('DB_PASSWORD', ''), database: config.get('DB_DATABASE', 'dorm_billing'), entities: allEntities, + migrations: allMigrations, synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', charset: 'utf8mb4', }; @@ -165,6 +168,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; return { type: 'better-sqlite3' as const, database: config.get('DB_DATABASE', 'dorm_billing.db'), + migrations: allMigrations, entities: allEntities, synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false', }; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 411dcab..8a6ae5d 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,15 +1,14 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; -import { DataSource } from 'typeorm'; +import { runMigrationsOnStartup } from './migration-runner'; async function bootstrap() { + await runMigrationsOnStartup(); + const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); app.enableCors(); - const dataSource = app.get(DataSource); - await dataSource.runMigrations(); - console.log('Migrations OK'); await app.listen(process.env.PORT ?? 3000); console.log(`Server running on http://localhost:${process.env.PORT ?? 3000}`); } diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts new file mode 100644 index 0000000..b37c94c --- /dev/null +++ b/apps/server/src/migration-runner.ts @@ -0,0 +1,24 @@ +import { DataSource } from 'typeorm'; +import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema'; +import { config } from 'dotenv'; + +config(); + +const isMySQL = (process.env.DB_TYPE || 'sqlite') === 'mysql'; + +export async function runMigrationsOnStartup(): Promise { + const ds = new DataSource({ + type: isMySQL ? 'mysql' : 'better-sqlite3', + host: isMySQL ? (process.env.DB_HOST || 'localhost') : undefined, + port: isMySQL ? Number(process.env.DB_PORT || 3306) : undefined, + username: isMySQL ? (process.env.DB_USERNAME || 'root') : undefined, + password: isMySQL ? (process.env.DB_PASSWORD || '') : undefined, + database: process.env.DB_DATABASE || (isMySQL ? 'dorm_billing' : 'dorm_billing.db'), + charset: isMySQL ? 'utf8mb4' : undefined, + migrations: [InitialSchema1784520727860], + }); + + await ds.initialize(); + await ds.runMigrations(); + await ds.destroy(); +} diff --git a/apps/server/src/migrations/1784520727860-InitialSchema.ts b/apps/server/src/migrations/1784520727860-InitialSchema.ts index 215fa31..eea4fb2 100644 --- a/apps/server/src/migrations/1784520727860-InitialSchema.ts +++ b/apps/server/src/migrations/1784520727860-InitialSchema.ts @@ -10,7 +10,7 @@ export class InitialSchema1784520727860 implements MigrationInterface { await queryRunner.query(`CREATE TABLE \`users\` (\`id\` int NOT NULL AUTO_INCREMENT, \`username\` varchar(50) NOT NULL, \`password_hash\` varchar(255) NOT NULL, \`name\` varchar(50) NULL, \`is_active\` tinyint NOT NULL DEFAULT 1, \`last_login_at\` datetime NULL, \`is_archived\` tinyint NOT NULL DEFAULT 0, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`profile\` text NULL, UNIQUE INDEX \`IDX_fe0bb3f6520ee0469504521e71\` (\`username\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`sync_state\` (\`platform\` varchar(20) NOT NULL, \`last_sync_at\` datetime NULL, \`run_id\` varchar(64) NULL, \`running_since\` datetime NULL, PRIMARY KEY (\`platform\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`sync_logs\` (\`id\` int NOT NULL AUTO_INCREMENT, \`platform\` varchar(20) NOT NULL, \`sync_type\` varchar(20) NOT NULL, \`status\` varchar(20) NOT NULL, \`records_count\` int NOT NULL DEFAULT '0', \`error_message\` text NULL, \`started_at\` datetime NULL, \`finished_at\` datetime NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); - await queryRunner.query(`CREATE TABLE \`room_expenses\` (\`id\` int NOT NULL AUTO_INCREMENT, \`room_id\` int NOT NULL, \`expense_type\` varchar(20) NOT NULL, \`amount\` decimal(10,2) NOT NULL, \`period_start\` date NOT NULL, \`period_end\` date NOT NULL, \`description\` text NULL, \`recorded_by\` int NULL, \`import_key\` varchar(120) NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_050485162d4fe47bd3cfd7dedb\` (\`import_key\`), UNIQUE INDEX \`IDX_050485162d4fe47bd3cfd7dedb\` (\`import_key\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`room_expenses\` (\`id\` int NOT NULL AUTO_INCREMENT, \`room_id\` int NOT NULL, \`expense_type\` varchar(20) NOT NULL, \`amount\` decimal(10,2) NOT NULL, \`period_start\` date NOT NULL, \`period_end\` date NOT NULL, \`description\` text NULL, \`recorded_by\` int NULL, \`import_key\` varchar(120) NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_050485162d4fe47bd3cfd7dedb\` (\`import_key\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`rooms\` (\`id\` int NOT NULL AUTO_INCREMENT, \`room_number\` varchar(20) NOT NULL, \`building\` varchar(50) NULL, \`floor\` int NULL, \`capacity\` int NOT NULL, \`status\` varchar(20) NOT NULL DEFAULT 'available', \`room_type\` varchar(20) NULL, \`rental_category\` varchar(10) NOT NULL DEFAULT 'short', \`monthly_rate\` decimal(10,2) NOT NULL DEFAULT '0.00', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_8f7c6fa4c469bab1a06fe3e49f\` (\`room_number\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`organizations\` (\`id\` int NOT NULL AUTO_INCREMENT, \`public_id\` varchar(36) NOT NULL, \`code\` varchar(50) NOT NULL, \`name\` varchar(100) NOT NULL, \`is_host\` tinyint NOT NULL DEFAULT 0, \`contact_name\` varchar(50) NULL, \`phone\` varchar(30) NULL, \`color\` varchar(20) NULL, \`notes\` text NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_0db5eda192bf60a02bd41931f8\` (\`public_id\`), UNIQUE INDEX \`IDX_7e27c3b62c681fbe3e2322535f\` (\`code\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`beds\` (\`id\` int NOT NULL AUTO_INCREMENT, \`room_id\` int NOT NULL, \`bed_number\` varchar(20) NOT NULL, \`status\` varchar(20) NOT NULL DEFAULT 'available', \`notes\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_95e9ba0a907346ef7b0d5ca488\` (\`room_id\`, \`bed_number\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); @@ -28,7 +28,7 @@ export class InitialSchema1784520727860 implements MigrationInterface { await queryRunner.query(`CREATE TABLE \`operation_logs\` (\`id\` int NOT NULL AUTO_INCREMENT, \`user_id\` int NULL, \`username\` varchar(50) NULL, \`module\` varchar(50) NOT NULL, \`action\` varchar(50) NOT NULL, \`target_id\` int NULL, \`target_type\` varchar(50) NULL, \`detail\` text NULL, \`ip_address\` varchar(50) NULL, \`user_agent\` varchar(500) NULL, \`status\` varchar(20) NULL DEFAULT 'success', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`notifications\` (\`id\` int NOT NULL AUTO_INCREMENT, \`recipient_id\` int NOT NULL, \`type\` varchar(30) NOT NULL, \`title\` varchar(200) NOT NULL, \`content\` text NULL, \`link\` varchar(500) NULL, \`is_read\` tinyint NOT NULL DEFAULT 0, \`read_at\` datetime NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`learning_records\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`record_date\` date NULL, \`record_type\` varchar(50) NULL, \`content\` text NULL, \`follow_up_method\` varchar(50) NULL, \`next_step\` text NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); - await queryRunner.query(`CREATE TABLE \`financial_operations\` (\`id\` int NOT NULL AUTO_INCREMENT, \`operation_id\` varchar(64) NOT NULL, \`type\` varchar(64) NOT NULL, \`status\` varchar(20) NOT NULL DEFAULT 'running', \`result_json\` text NULL, \`error_message\` varchar(500) NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` (\`operation_id\`), UNIQUE INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` (\`operation_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`financial_operations\` (\`id\` int NOT NULL AUTO_INCREMENT, \`operation_id\` varchar(64) NOT NULL, \`type\` varchar(64) NOT NULL, \`status\` varchar(20) NOT NULL DEFAULT 'running', \`result_json\` text NULL, \`error_message\` varchar(500) NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` (\`operation_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`expense_types\` (\`id\` int NOT NULL AUTO_INCREMENT, \`code\` varchar(30) NOT NULL, \`name\` varchar(30) NOT NULL, \`category\` varchar(20) NOT NULL DEFAULT 'room', \`sort_order\` int NOT NULL DEFAULT '0', \`enabled\` tinyint NOT NULL DEFAULT 1, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_36eda3eb0f6740ecf2ba906012\` (\`code\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`exam_scores\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`enrollment_id\` int NULL, \`exam_type\` varchar(50) NULL, \`exam_name\` varchar(100) NULL, \`subject\` varchar(50) NULL, \`score\` decimal(5,2) NULL, \`class_avg\` decimal(5,2) NULL, \`rank\` int NULL, \`exam_date\` date NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`ding_attendance_raw\` (\`id\` int NOT NULL AUTO_INCREMENT, \`ding_user_id\` varchar(100) NOT NULL, \`user_name\` varchar(100) NOT NULL, \`attendance_date\` date NOT NULL, \`ding_id\` varchar(100) NOT NULL, \`check_in_time\` datetime NULL, \`check_out_time\` datetime NULL, \`attendance_type\` varchar(20) NOT NULL, \`time_result\` varchar(20) NOT NULL, \`location_result\` varchar(20) NULL, \`punch_source\` varchar(40) NULL, \`punch_device_name\` varchar(100) NULL, \`punch_device_id\` varchar(100) NULL, \`match_status\` varchar(20) NOT NULL DEFAULT 'unmatched', \`matched_student_id\` int NULL, \`raw_data\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), INDEX \`IDX_d6680d5150f0bfa47dbc1fe96f\` (\`match_status\`), INDEX \`IDX_088de070f537b15ab7da255e5b\` (\`attendance_date\`), UNIQUE INDEX \`IDX_997849bb04ff69149fcf00a8d3\` (\`ding_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); @@ -193,7 +193,6 @@ export class InitialSchema1784520727860 implements MigrationInterface { await queryRunner.query(`DROP INDEX \`IDX_36eda3eb0f6740ecf2ba906012\` ON \`expense_types\``); await queryRunner.query(`DROP TABLE \`expense_types\``); await queryRunner.query(`DROP INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` ON \`financial_operations\``); - await queryRunner.query(`DROP INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` ON \`financial_operations\``); await queryRunner.query(`DROP TABLE \`financial_operations\``); await queryRunner.query(`DROP TABLE \`learning_records\``); await queryRunner.query(`DROP TABLE \`notifications\``); diff --git a/docker-compose.yml b/docker-compose.yml index 86ecd69..5e320ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.8' services: backend: From 00e9c5e45a609386586ce26f0db5905ec8a01d21 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 20 Jul 2026 14:45:53 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E5=A4=84?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=E6=9D=A1=E4=BB=B6=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rooms: parseRoomNumber 未知格式返回默认 capacity=4,防止 undefined 绕过入住容量检查 - rooms: 修复 parseInt() || undefined 导致楼层 0 被吞掉 - rooms: batchImport 中 capacity 使用 ?? 代替 ||,显式 0 不被覆盖 - occupancies: 所有 capacity 比较加 ?? 0 防守兜底,fail closed - schedules: assertValidScheduleRange 增加 startTime > endTime 校验 - attendance: 时段重叠检查改为按 startTime 排序后再比较,消除漏检 - attendance: 移除 getScheduleOptionsForAttendance 中不可靠的 raw[index] fallback - expenses: 个人附加费批量导入增加 assertPositiveAmount 校验 - expenses: 水电费导入增加 periodEnd >= periodStart 校验 --- .../src/attendance/attendance.service.ts | 16 ++++++++++------ apps/server/src/expenses/expenses.service.ts | 14 ++++++++++++++ .../src/occupancies/occupancies.service.ts | 19 +++++++++---------- apps/server/src/rooms/rooms.controller.ts | 2 +- apps/server/src/rooms/rooms.service.ts | 10 ++++++---- .../server/src/schedules/schedules.service.ts | 3 +++ 6 files changed, 43 insertions(+), 21 deletions(-) diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index 3218b81..e1d27b2 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -866,9 +866,13 @@ export class AttendanceService { }; }).sort((left, right) => left.sortOrder - right.sortOrder); - for (let index = 1; index < normalized.length; index += 1) { - const previous = normalized[index - 1]; - const current = normalized[index]; + // 按开始时间排序后再检查重叠,避免 sortOrder 与时间顺序不一致时漏检 + const sortedByTime = [...normalized].sort( + (left, right) => this.toMinutes(left.startTime) - this.toMinutes(right.startTime), + ); + for (let index = 1; index < sortedByTime.length; index += 1) { + const previous = sortedByTime[index - 1]; + const current = sortedByTime[index]; if (previous.enabled && current.enabled && this.toMinutes(current.startTime) < this.toMinutes(previous.endTime)) { throw new BadRequestException(`${previous.label} 和 ${current.label} 时间段不能重叠`); } @@ -996,10 +1000,10 @@ export class AttendanceService { ]), ); - return entities.map((schedule, index) => { + return entities.map((schedule) => { const teacher = teacherByScheduleId.get(schedule.id) ?? { - teacherName: raw[index]?.teacherName || null, - teacherUsername: raw[index]?.teacherUsername || null, + teacherName: null, + teacherUsername: null, }; return { ...schedule, ...teacher }; }); diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index d818e33..efa1e38 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -291,6 +291,11 @@ export class ExpensesService { skipped++; continue; } + if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { + errors.push(`第${rowNum}行: ${row.roomNumber} 账期无效(${periodStart} ~ ${periodEnd}),已跳过`); + skipped++; + continue; + } // 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失, // 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。 @@ -439,6 +444,15 @@ export class ExpensesService { } } + // 校验金额 + try { + this.assertPositiveAmount(row.amount); + } catch (e: any) { + errors.push(`第${rowNum}行: ${row.studentName} ${e.message}`); + skipped++; + continue; + } + await this.personalExpRepo.save( this.personalExpRepo.create({ studentId: student.id, diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 496aebb..5ff0881 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -82,7 +82,7 @@ export class OccupanciesService { const count = await manager.count(Occupancy, { where: { roomId: dto.roomId, checkOutDate: IsNull() }, }); - if (count >= room.capacity) throw new BadRequestException('宿舍已满'); + if (count >= (room.capacity ?? 0)) throw new BadRequestException('宿舍已满'); const student = await manager.findOne(Student, { where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); @@ -124,8 +124,7 @@ export class OccupanciesService { ); if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' }); if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' }); - if (count + 1 >= room.capacity) await manager.update(Room, room.id, { status: 'full' }); - + if (count + 1 >= (room.capacity ?? 0)) await manager.update(Room, room.id, { status: 'full' }); if (dto.collectDeposit) { let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } }); if (deposit) { @@ -225,7 +224,7 @@ export class OccupanciesService { const count = await runner.manager.count(Occupancy, { where: { roomId: dto.newRoomId, checkOutDate: IsNull() }, }); - if (count >= newRoom.capacity) throw new BadRequestException('目标宿舍已满'); + if (count >= (newRoom.capacity ?? 0)) throw new BadRequestException('目标宿舍已满'); // 新床位校验 if (dto.newBedId) { @@ -286,7 +285,7 @@ export class OccupanciesService { await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' }); } - if (count + 1 >= newRoom.capacity) { + if (count + 1 >= (newRoom.capacity ?? 0)) { await runner.manager.update(Room, newRoom.id, { status: 'full' }); } @@ -556,9 +555,9 @@ export class OccupanciesService { // 4. 检查宿舍容量 const count = await occupancyRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() } }); - if (!isHistoricalRecord && count >= room.capacity) { + if (!isHistoricalRecord && count >= (room.capacity ?? 0)) { throw new ImportRowSkipped( - `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`, + `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity ?? '?'}),跳过 ${row.name}`, ); } @@ -569,9 +568,9 @@ export class OccupanciesService { bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } }); if (!bed) { const existingBedCount = await bedRepo.count({ where: { roomId: room.id } }); - if (existingBedCount >= room.capacity) { + if (existingBedCount >= (room.capacity ?? 0)) { throw new BadRequestException( - `宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity}`, + `宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity ?? '?'}`, ); } bed = await bedRepo.save( @@ -620,7 +619,7 @@ export class OccupanciesService { if (!isHistoricalRecord) { if (bed) await bedRepo.update(bed.id, { status: 'occupied' }); if (locker) await lockerRepo.update(locker.id, { status: 'occupied' }); - if (count + 1 >= room.capacity) { + if (count + 1 >= (room.capacity ?? 0)) { await roomRepo.update(room.id, { status: 'full' }); } } diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index 8d10c92..88ff447 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -349,7 +349,7 @@ export class RoomsController { rows.push({ roomNumber: String(row.getCell(1).value || ''), building: String(row.getCell(2).value || '') || undefined, - floor: Number(row.getCell(3).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, rentalCategory, diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index ba4c63c..896848c 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -50,7 +50,8 @@ export class RoomsService { if (familyMatch) { const bldg = `${familyMatch[1]}-${familyMatch[2]}栋`; const roomPart = familyMatch[3]; - const floor = parseInt(roomPart.charAt(0), 10) || undefined; + const rawFloor = parseInt(roomPart.charAt(0), 10); + const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; return { building: bldg, floor, roomType: '家庭房', capacity: 4 }; } // 标准: X-YZZ 格式 @@ -58,7 +59,8 @@ export class RoomsService { if (stdMatch) { const bldgNum = stdMatch[1]; const roomPart = stdMatch[2]; - const floor = parseInt(roomPart.charAt(0), 10) || undefined; + const rawFloor = parseInt(roomPart.charAt(0), 10); + const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; const building = `${bldgNum}号楼`; let roomType = '四人间'; let capacity = 4; @@ -71,7 +73,7 @@ export class RoomsService { } return { building, floor, roomType, capacity }; } - return {}; + return { capacity: 4, roomType: '四人间' }; } async findAll(query?: { building?: string; includeArchived?: boolean }) { @@ -374,7 +376,7 @@ export class RoomsService { roomNumber: row.roomNumber.trim(), building: row.building?.trim() || parsed.building || undefined, floor: row.floor ?? parsed.floor, - capacity: row.capacity || parsed.capacity || 4, + capacity: row.capacity ?? parsed.capacity ?? 4, roomType: row.roomType || parsed.roomType || undefined, rentalCategory: row.rentalCategory || undefined, monthlyRate: row.monthlyRate ?? undefined, diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index 57217e5..d89a8ff 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -198,6 +198,9 @@ export class SchedulesService { if (startTime === endTime) { throw new BadRequestException('上课时间和下课时间不能相同'); } + if (startTime > endTime) { + throw new BadRequestException('上课时间不能晚于下课时间'); + } if (startDate > endDate) { throw new BadRequestException('排课结束日期不能早于开始日期'); }