`CreateLockerDto.status` is a free-form `@IsString()` (no enum), so `createLocker` can create a locker directly in `'archived'` or `'occupied'` state, bypassing the state-machine rules enforced by `deleteLocker`/`updateLocker` (e.g., occupied-only-after-check-in). The same issue exists in `createBed` via `CreateBedDto.status`. Either validate with `@IsEnum(['available', 'maintenance'])` (excluding `occupied`/`archived`) or default the status and ignore client-supplied values.

    const locker = this.lockerRepo.create({ ...dto, roomId });
    return this.lockerRepo.save(locker);


The next-number computation is duplicated inline instead of reusing the already-defined `getNextBedNumber(beds)`, and it ignores archived rows: since `deleteBed` leaves archived beds in the table (e.g. "5号床"), batch creation starting at `max(non-archived numbers) + 1` can generate a number that collides with an existing archived bed, and `batchCreateBeds` performs no duplicate check at all (unlike `createBed`). Reuse `getNextBedNumber` and guard against conflicts with archived bed numbers; same for `batchCreateLockers`.

const start = this.getNextBedNumber(existing);

    const numbers = existing.map((b) => {
      const match = b.bedNumber.match(/^\d+/);
      return match ? parseInt(match[0]) : 0;
    });
    const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;


The guard only blocks `occupied → maintenance`; the same endpoint allows `occupied → available` directly, which skips the intended check-out flow that the comment alludes to ("请先退宿"). If occupied status must be cleared only through a proper checkout, this transition needs the same protection — and the corresponding `updateLocker` has the same asymmetry.

    // 不允许将 occupied 的床位改为 maintenance
    if (dto.status === 'maintenance' && bed.status === 'occupied') {
      throw new BadRequestException('该床位有人入住，请先退宿');
    }


The generated conditions never restrict `status` to 'active', so archived occupancy rows are also returned as "occupying on date". Other occupancy queries in this codebase consistently filter `status: 'active'` (e.g. dashboard-queries.service.ts uses `o.status = 'active'` on the occupancies table). If an archived/cancelled occupancy still has `checkOutDate = NULL` or a future `checkOutDate`, it will be wrongly counted as an active occupant. Consider adding `status: 'active'` to both branches (or have callers add it).

  return [
    { ...scope, status: 'active', checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() },
    { ...scope, status: 'active', checkInDate: LessThanOrEqual(date), checkOutDate: MoreThan(date) },
  ];

  return [
    { ...scope, checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() },
    { ...scope, checkInDate: LessThanOrEqual(date), checkOutDate: MoreThan(date) },
  ];


The guard only handles `undefined`, not `null`. If a caller ever passes `null` for `roomId` (e.g. from unvalidated query params), the spread produces `{ roomId: null }`, which TypeORM translates to `room_id IS NULL` and silently returns no/wrong rows instead of "all rooms". Handle both `undefined` and `null` defensively.

const scope = roomId === undefined || roomId === null ? {} : { roomId };

const scope = roomId === undefined ? {} : { roomId };


N+1 query issue: getRoomOverview issues one COUNT query per room in a loop. For a large dorm complex this results in N+1 round trips. Prefer a single grouped query, e.g. `this.occRepo.createQueryBuilder('o').select('o.roomId', 'roomId').addSelect('COUNT(o.id)', 'count').where('o.checkOutDate IS NULL').andWhere('o.roomId IN (:...ids)', { ids: rooms.map(r => r.id) }).groupBy('o.roomId').getRawMany()`, then join the counts into the result map.

    for (const room of rooms) {
      const count = await this.occRepo.count({
        where: { roomId: room.id, checkOutDate: IsNull() },
      });
      result.push({ ...room, currentCount: count });
    }


create() is not atomic: the room is saved first, then default beds are created in a separate, non-transactional step. If createDefaultBeds fails (e.g. DB hiccup), the room is left persisted without beds. Additionally, room.roomNumber has a DB unique constraint, but there is no pre-check or unique-violation handling here, so a duplicate room number will surface as a raw 500 DB error instead of a 4xx. Wrap both operations in a transaction (e.g. `this.dataSource.transaction`) and handle the duplicate key case explicitly.

    const room = await this.repo.save(entity);
    await this.createDefaultBeds(room.id, room.capacity);
    return room;


Ordering by `roomNumber` (and `building` in getRoomOverview) uses lexicographic string comparison. With room numbers like "4-102" and "4-22", or buildings like "10号楼" and "2号楼", the result order is wrong ("102" sorts before "22"). Either sort numerically in JS after fetching (parse the leading number), or use an expression-based ORDER BY in the query builder (e.g. `ORDER BY CAST(SUBSTRING_INDEX(roomNumber,'-',-1) AS UNSIGNED)`).

return this.repo.find({ where, order: { roomNumber: 'ASC' } });


TOCTOU race in batchRemove/batchPurge: the active-occupancy check and the status update / delete are not performed in a transaction. A student could check in (occupancy inserted) between the `count` check and the archive update, leaving an archived room with active occupants. The per-room checks are also run sequentially; they could be batched with a single `IN` + `GROUP BY` query and executed inside one transaction (or with pessimistic locking) to keep check-and-act consistent.

      const activeCount = await this.occRepo.count({
        where: { roomId: r.id, checkOutDate: IsNull() },
      });


batchImport performs two sequential DB round-trips per row (findOne + save) in a for-await loop, i.e. O(2N) queries for a bulk import. Rows are independent, so this is needlessly slow for large files. Suggest de-duplicating roomNumbers within the batch, checking existing rooms with a single `In(...)` query, then inserting the remainder with a transaction / Promise.all.

      const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
      if (exists) {
        skipped++;
        continue;
      }


batchImport is not wrapped in a transaction and has no per-row error handling: if a later row fails (e.g. unique room_number constraint hit by a concurrent import, or a bad value), the rooms already saved earlier in the loop remain persisted, leaving a partial import, and the raw DB error is thrown to the caller without a user-friendly message. Wrap the loop in `this.repo.manager.transaction(...)` (or skip-and-continue per-row on expected failures) so imports are atomic and reported cleanly.

      const room = await this.repo.save(
        this.repo.create({
          roomNumber: row.roomNumber.trim(),


checkOutDate boundary is inconsistent with the shared occupancyWhereOnDate helper (which uses strict `MoreThan(date)`): here `o.checkOutDate >= :date` still counts an occupant who checked out on the target date as occupied, while getRoomVisual/occupancyWhereOnDate treats them as vacated that day. The two endpoints can report different occupancy counts for the same room/date. Align both to the same boundary (e.g. `>`).

.andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :date)', { date: targetDate });


Uses loose `== null` (also on `row.room_floor` below), which violates the project rule requiring strict equality. Since getRawMany returns SQL NULL as `null`, `=== null` is safe here; alternatively use `String(row.room_building ?? '')` / `Number(row.room_floor ?? 0)`.

building: row.room_building == null ? null : String(row.room_building),


`buildings` is computed from all `rooms` before archived-room filtering. In the historical view, an archived room with no occupants on the target date is hidden from `visibleRooms`, yet its building can still appear in the returned dropdown with no visible rooms behind it. Derive the list from `visibleRooms` instead.

const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))];


No upload size limit on FileInterceptor, and the entire file is loaded into memory via `file.buffer` then copied again into an ArrayBuffer (`bufferToArrayBuffer`). An authenticated user can upload an arbitrarily large file, doubling memory usage and exhausting server memory (DoS). Add a `limits: { fileSize }` to the interceptor and/or stream the file instead of buffering it whole.

@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))


`file` is not null-checked: if the request omits the `file` field (or uses the wrong field name), `@UploadedFile()` yields `undefined` and `file.buffer` throws an unhandled TypeError, producing a generic 500. Guard against a missing file and return a user-friendly 400 error instead.

    if (!file) {
      throw new BadRequestException('请上传 Excel 文件');
    }
    const workbook = new ExcelJS.Workbook();
    await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));


`Number(...) || 4` silently coerces an empty cell, `0`, `'0'`, or any non-numeric string into the default capacity 4. Imported data is thus silently corrupted (e.g. a room with 0 seats or a typo like '1O' becomes a 4-person room). Validate the cell explicitly (finite and > 0) instead of relying on truthiness.

capacity: (n => (Number.isFinite(n) && n > 0 ? n : 4))(Number(row.getCell(4).value)),

capacity: Number(row.getCell(4).value) || 4,


`batchRemove` (and likewise `batchPurge`) takes an unvalidated `@Body() body: { ids: number[] }`. If `ids` is missing, a string, or a non-array value, `body.ids || []` passes garbage to the service and `(body.ids || []).join(',')` throws a TypeError (e.g. for a string `ids`) or a 500. This is also inconsistent with `batchRestore`, which uses `BatchIdsDto` with a strict ValidationPipe — reuse the same validated DTO here.

  @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
  async batchRemove(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
    const result = await this.service.batchRemove(dto.ids);

  async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
    const result = await this.service.batchRemove(body.ids || []);


Using `@Res() res?: Response` with manual `res.setHeader`/`res.write`/`res.end()` bypasses Nest's response lifecycle. If `workbook.xlsx.write(res)` throws after headers are sent, the response is never ended and the client hangs; also `res!` non-null assertions suggest the optional param is really always present. Prefer streaming the workbook and relying on Nest's exception handling, or at minimum wrap the write in try/finally to guarantee `res.end()`.

async exportExcel(@Query('includeArchived') includeArchived?: string, @Res() res?: Response) {


Import rows are pushed without any filtering: `ws.eachRow` iterates rows inside the sheet's used range, so blank rows (or rows left over from deleted data) produce entries with an empty `roomNumber: ''` that get sent to `batchImport`, risking garbage records or a confusing import failure. Skip rows whose room number is empty/whitespace before collecting.

      const roomNumber = cellValueText(row.getCell(1).value).trim();
      if (!roomNumber) return;
      rows.push({
        roomNumber,

      rows.push({
        roomNumber: cellValueText(row.getCell(1).value),


Missing cross-field validation: nothing enforces that `endTime` is later than `startTime` (nor that `endDate` >= `startDate`). A request like startTime "18:00" / endTime "09:00" or an endDate before startDate will pass validation and produce an invalid schedule. Consider a custom validator (or @ValidateIf-based check) to ensure time/date ranges are consistent.

  @IsMilitaryTime()
  @IsNotEmpty()
  endTime: string;


Every field in UpdateScheduleDto is optional, so an empty payload `{}` passes validation and reaches the service. This can silently become a no-op or, depending on the update implementation, unintentionally clear/overwrite fields. Consider rejecting empty updates, e.g. add `@IsNotEmptyObject()` on the DTO or validate that at least one field is present.

export class UpdateScheduleDto {
  @IsOptional()
  @IsInt()
  classId?: number;


`scheduleType` is a free-form string with no constraint, unlike `status` which is validated with @IsIn. Any typo (e.g. "Recurring" vs "recurring") is silently accepted and stored. If this field has a known set of allowed values, validate it with @IsIn(['...']) for consistency with `status`; otherwise a max length / pattern check would at least bound the input.

  @IsOptional()
  @IsString()
  scheduleType?: string;


Authorization bypass: in schedules.service.ts, `findAll` only applies the `accessibleClassIds` restriction in the `else if` branch — when `query.classId` is provided, the accessible-class filter is skipped entirely. A teacher with only `schedule:view` can therefore pass an arbitrary `classId` and read schedules of classes they are not assigned to. Validate that `query.classId` is within the computed `classIds` here (and similarly for other handlers that forward a `classId` filter).

    if (query.classId !== undefined && classIds && !classIds.includes(query.classId)) {
      return [];
    }
    return this.service.findAll(query, classIds);


Dead code / broken conflict notification: `this.service.checkConflict()` throws a `ConflictException` whenever a conflict is found (schedules.service.ts lines ~318 and ~333), so re-invoking it inside this catch will throw again and be silently swallowed by the empty `catch {}` — `notifyScheduleConflict` is effectively unreachable and affected teachers are never notified. Provide a non-throwing variant (e.g., `findConflicts(...)`) that returns the conflicting schedules, or derive the conflicts from the thrown exception.

this.notifyScheduleConflict(conflicts, dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, '');


Wrong values used for the conflict re-check after a failed update: the check uses `existing.*` (the pre-update stored values) instead of the new values proposed in the DTO. Since the update failed precisely because the NEW values conflict, checking the old values will not surface the actual conflicting schedule, and the notification would report the wrong classroom/time. Use `dto.classroomId ?? existing.classroomId` / `dto.weekDay ?? existing.weekDay` / `dto.startTime ?? existing.startTime` etc., mirroring the fallback logic already used in schedules.service.ts `update()`. Note also that `checkConflict` throws when conflicts exist, so it will be swallowed here — use a non-throwing query instead.

          const conflicts = await this.service.checkConflict(
            existing.classroomId,
            existing.weekDay,


Loose equality `==` is prohibited by the review rules; use strict equality. `schedule.classId == null` matches both `null` and `undefined` — express that explicitly.

if (schedule.classId === null || schedule.classId === undefined) {


This `getAccessibleClassIds(req.user.id, this.canManageAllSchedules(req))` pattern is duplicated in 5 handlers (getLookups, findAll, getWeeklyView, getClassTeachers, getClassroomOccupancy). Extract a private helper (e.g., `private getAccessibleClassIds(req: { user: RequestUser })`) to avoid repetition and keep the authorization logic consistent.

    const classIds = await this.service.getAccessibleClassIds(
      req.user.id,
      this.canManageAllSchedules(req),
    );


CreateStudentDto and UpdateStudentDto duplicate nearly all field definitions (name, studentNo, phone, idNumber, gender, ethnicity, emergencyContact, emergencyPhone, organizationId, supervisor). If a field is added/renamed in one DTO but forgotten in the other, validations silently diverge. Consider extracting a shared base class (e.g., `StudentBaseDto`) and having UpdateStudentDto extend it, or use `PartialType(CreateStudentDto)` from `@nestjs/mapped-types`/`@nestjs/swagger` to keep the two in sync.

export class CreateStudentDto {
  @IsString()
  name: string;


`organizationId` lacks `@Type(() => Number)` here (and in UpdateStudentDto), unlike QueryStudentDto which applies it. When the payload arrives as a string (form-urlencoded bodies, multipart, or proxies that serialize numbers to strings), `@IsInt()` will reject a valid numeric value like "5". Add `@Type(() => Number)` for consistent and reliable type coercion.

  @Type(() => Number)
  @IsInt()
  organizationId: number;


The fallback `return value;` in the boolean transform passes unrecognized inputs (e.g., "TRUE", "Yes", "") straight to `@IsBoolean()`, which rejects them with a generic, hard-to-debug 400 message. Normalize case-insensitively (e.g., also accept 'TRUE'/'yes') and/or throw a descriptive error (or return undefined) for invalid values so callers understand which query parameter was malformed.

  @Transform(({ value }: { value: unknown }) => {
    if (typeof value === 'boolean') return value;
    if (value === 'true' || value === '1') return true;
    if (value === 'false' || value === '0') return false;
    return value;
  })


The status literal `'active'` is hardcoded here even though the `ACTIVE_SCHEDULE_STATUS` constant defined at the top of this file is already used by `getWeeklyView` and `getClassroomOccupancy`. Reuse the constant to keep the status value in one place and avoid divergence.

.where('cs.status = :active', { active: ACTIVE_SCHEDULE_STATUS });

.where('cs.status = :active', { active: 'active' });


Project rules require strict equality (`===`/`!==`); `==`/`!=` are prohibited. Here `== null` is used in several places (`cs_class_id`, `class_name`, `classroom_name`, `teacher_name`). Since raw query values can also be `undefined`, use an explicit null/undefined check.

classId: row.cs_class_id === null || row.cs_class_id === undefined ? null : Number(row.cs_class_id),

classId: row.cs_class_id == null ? null : Number(row.cs_class_id),


All three public async methods (`agentSearchSchedules`, `getWeeklyView`, `getClassroomOccupancy`) execute DB queries without any error handling (try/catch). If the surrounding code does not rely on a global exception filter, DB failures will surface as raw 500s without a user-friendly message. Consider centralizing error handling or adding try/catch that maps failures to a friendly error.

const rows = await qb
      .orderBy('cs.weekDay', 'ASC')


Duplicate logic: this ability construction plus the `isSuperAdmin || can(Update, Class)` condition duplicates `AgentBusinessScopeFactory.canManageAllClasses` in agent-tools/agent-business-scope.factory.ts (which is also the condition used by the HTTP layer in students.controller.ts). Keeping the same rule in two factories risks silent divergence — e.g. if one adds an extra permission condition, the agent student scope and business scope will disagree. Consider injecting/reusing `AgentBusinessScopeFactory` (or extracting a shared helper) so the `manageAll` decision is defined in one place.

    const ability = this.abilityFactory.createForUser({
      permissions: context.permissions,
      isSuperAdmin: context.isSuperAdmin,
    });

    // class:edit (Update Class) or super admin grants full student scope
    if (context.isSuperAdmin || ability.can(CaslAction.Update, SubjectName.Class)) {
      return { type: 'manageAll' };
    }

    const ability = this.abilityFactory.createForUser({
      permissions: context.permissions,
      isSuperAdmin: context.isSuperAdmin,
    });

    if (context.isSuperAdmin) {
      return { type: 'manageAll' };
    }

    // class:edit (Update Class) grants full student scope
    if (ability.can(CaslAction.Update, SubjectName.Class)) {
      return { type: 'manageAll' };
    }


The explicit `context.isSuperAdmin` branch is redundant: `CaslAbilityFactory.createForUser` already grants `manage all` when `isSuperAdmin` is true, so `ability.can(CaslAction.Update, SubjectName.Class)` below would also be true and return `manageAll`. Additionally, the ability is constructed before the super-admin early return, so the branch doesn't even save the ability build. Keep it only as an intentional readability choice, or collapse it into the ability check; otherwise readers may assume it handles a case the CASL ability doesn't already cover.

    if (context.isSuperAdmin) {
      return { type: 'manageAll' };
    }

    // class:edit (Update Class) grants full student scope
    if (ability.can(CaslAction.Update, SubjectName.Class)) {


`batchImport` performs multiple DB writes per row (existence check, student insert, archive imports) with no try/catch and no transaction. A single bad row (e.g., unique-constraint violation on studentNo/phone/idNumber, or an invalid archive row) aborts the entire import with a raw DB error, while rows already processed remain partially committed. Consider wrapping the import in a transaction and handling per-row errors (collect failures and continue) so one bad row doesn't discard the whole workbook.

      const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
      if (exists) {
        skipped++;
        continue;
      }


The manual find-then-save upsert pattern (enrollment/examScore/learningRecord, and the name-based existence check in batchImport) is racy: two concurrent imports can both pass the existence check and insert duplicate rows. Use DB unique constraints together with `repo.upsert()`/`ON CONFLICT`, or serialize imports. Also note batchImport deduplicates purely by `name`, so a legitimate second student with the same name as an existing one is silently skipped rather than imported.

    const existing = await this.enrollmentRepo.find({ where: { studentId } });
    const entity =
      existing.find(
        (item) =>
          this.sameValue(item.courseCategory, row.courseCategory) &&


Sequential per-row DB round trips create an N+1 pattern: each student incurs `findOne` + `save` + several archive queries awaited one after another, and `data.enrollments/examScores/learningRecords` are re-filtered for every student (O(rows × students)). `getHostOrganizationId` is also re-queried for every row lacking an organizationId. Consider pre-indexing the workbook arrays by normalized phone once, caching the host organization, and processing independent rows with bounded-concurrency `Promise.all`.

    for (const enrollmentRow of data.enrollments.filter(
      (item) => this.normalizePhone(item.phone) === phone,
    )) {


The business status value `'active'` is hardcoded in multiple methods (enrollment, exam score, learning record). Extract a named constant (e.g. `ENROLLMENT_STATUS_ACTIVE`) so the status stays consistent and typo-safe.

else if (!entity.status) entity.status = 'active';


matchImport blindly overwrites every non-empty row field onto the matched student without cross-checking identifiers. If a row's phone matches student A while its idNumber/studentNo belongs to student B (or vice versa), A gets corrupted with B's data silently. Before applying updates, verify that all provided identifiers (phone, idNumber, studentNo) are consistent with the matched student, or skip/reject rows where they conflict.

      if (row.name?.trim()) updates.name = row.name.trim();
      if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
      if (row.phone?.trim()) updates.phone = row.phone.trim();
      if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();


Authorization bypass in `findAll`: when a caller supplies `query.classId`, the `accessibleClassIds` scope is skipped entirely (the `else if` branch never runs). The controller passes the teacher's accessible class IDs straight through without validating that `query.classId` belongs to the user, so a restricted teacher can list schedules of any class by passing its id — unlike `schedule-queries.service.ts` which rejects `classId` outside the accessible set. Enforce the intersection here, e.g. return `[]` when `accessibleClassIds` is defined and does not contain `query.classId`.

    if (query.classId) {
      if (accessibleClassIds && !accessibleClassIds.includes(query.classId)) return [];
      qb.andWhere('cs.classId = :classId', { classId: query.classId });
    } else if (accessibleClassIds) {

    if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId });
    else if (accessibleClassIds) {


Check-then-act race condition: `checkConflict` runs a read query and the insert/update (`save` / `update`) happens afterwards with no transaction, lock, or DB constraint. Two concurrent create/update requests for the same classroom/time slot can both pass the conflict check and persist overlapping schedules (double booking). Consider wrapping the conflict check + write in a transaction with `PESSIMISTIC_WRITE` locking (e.g. on the classroom row) or adding a DB constraint to make reservation atomic.

    const conflicts = await qb.getMany();
    if (conflicts.length > 0) {


Hardcoded business values: `roleType: 'subject_teacher'` (and the rental `status: 'active'` filter inside `checkConflict`) are business literals. The file already defines schedule status constants and the codebase defines a `ClassroomRentalStatus` enum, but these strings are free-floating literals that can silently drift. Extract them into shared constants/enums so typos are caught by the compiler.

where: { classId: dto.classId, roleType: 'subject_teacher', subject: dto.subject },


`attendanceSessionRepo` is injected in the constructor but never used anywhere in this service — remove this unused dependency.

    @InjectRepository(AttendanceSession)
    private readonly attendanceSessionRepo: Repository<AttendanceSession>,


`checkConflict` always throws a `ConflictException` when `conflicts` is non-empty, so the trailing `return conflicts` can only ever be an empty array, and no caller uses the return value (the controller re-invokes the method and discards the result). Consider returning `void` (or `never`) to avoid a misleading API contract.


`update` mutates the incoming `dto` object (`dto.teacherId = normalized.teacherId`), creating a side effect on the caller-owned object and making the subsequent `scheduleRepo.update(id, dto)` depend on this mutation having run first. Build a separate normalized payload instead of mutating the parameter.

      if (dto.teacherId === undefined && normalized.teacherId !== undefined) {
        dto.teacherId = normalized.teacherId;
      }


`@UploadedFile() file` is not null-checked. If the client submits a multipart request without the `file` field, `file` is undefined and `file.buffer` throws a TypeError resulting in an opaque 500. Add a guard and return 400 (e.g. `if (!file) throw new BadRequestException('请上传文件')`, importing `BadRequestException` from '@nestjs/common'). Apply to both import endpoints.

  async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
    if (!file) {
      throw new BadRequestException('请上传文件');
    }
    const workbook = new ExcelJS.Workbook();
    await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));

  async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
    const workbook = new ExcelJS.Workbook();
    await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));


This loop performs one `organizationRepo.findOne` query per row sequentially (N+1 problem). For large import files this is serialized round-trips, and duplicate organization names cause repeated identical queries. Collect the unique names first, query once with `In(...)`, build a Map<name, id>, then assign in the loop. The same block is duplicated in `matchImport` and should use the same fix.

    const orgNames = [...new Set(importData.students.map((r) => r.organization).filter(Boolean))];
    const orgs = orgNames.length
      ? await this.organizationRepo.find({ where: { name: In(orgNames) } })
      : [];
    const orgMap = new Map(orgs.map((o) => [o.name, o.id]));
    for (const row of importData.students) {
      if (row.organization && orgMap.has(row.organization)) {
        row.organizationId = orgMap.get(row.organization);
      }
    }

    for (const row of importData.students) {
      if (row.organization) {
        const organization = await this.organizationRepo.findOne({
          where: { name: row.organization },
        });
        if (organization) {
          row.organizationId = organization.id;
        }
      }
    }


List endpoints (`findAll`, `filter-lookups`, `export`) restrict data to `getAccessibleClassIds(req.user.id, canManageAll)`, but the by-id read (`findOne`, `compareClasses`) and all mutation endpoints (`update`, `remove`, `purge`, `restore`, `batch*`) call the service without any class scope. Confirmed the service methods (`findOne(id)`, `update(id, dto)`, `remove(id)`, etc.) only take an id and do not apply scoping. A teacher holding `student:view`/`student:edit`/`student:delete`/`student:purge` can therefore read, modify, archive or permanently delete students in classes they do not teach (cross-class IDOR). Pass the accessible-class scope (or `canManageAll` flag) into these service calls and verify membership before acting.

  @Get(':id')
  @RequirePermission('student:view')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.service.findOne(id);
  }


In this system `idNumber` is the national ID number (身份证号, exported as its own column) while `studentNo` is the 学号 field. Logging `学号: ${dto.idNumber}` both mislabels the field and persists full national ID numbers (sensitive PII) into audit logs; the `update` handler similarly dumps `JSON.stringify(dto)`. Use `dto.studentNo` and/or mask `idNumber` in audit details.

    await logAudit(this.logService, req, {
      module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.studentNo || '无'}`,
    });

    await logAudit(this.logService, req, {
      module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,
    });


The organization-name-to-ID resolution block is duplicated verbatim between `importExcel` and `matchImport`; the two paths can drift. Extract it into a private helper (e.g. `private async resolveOrganizationIds(students: ...)` with a single batch query as noted) and call it from both handlers.

        if (organization) row.organizationId = organization.id;
      }
    }
    const result = await this.service.matchImport(importData);


The cron-triggered path has no error handling. If `settleDate` throws (e.g., a DB outage, or `lockRoom` raises '宿舍不存在' because a room was deleted between the occupancies read and the room lock), the rejected promise from this async method is never awaited/caught by the scheduler — this can surface as an unhandled rejection (process crash on Node ≥15) and also aborts settlement for every remaining room, leaving the day's records silently incomplete. `onApplicationBootstrap` wraps the same call with `.catch()` + logging, but the cron path does not. Add a try/catch with `logger.error` here, and consider isolating per-room failures inside `settleDate` so one bad room doesn't abort the whole batch.

    try {
      const today = this.getChinaDate(now);
      const targetDate = this.shiftDate(today, -1);
      await this.settleDate(targetDate);
    } finally {
      this.settling = false;
    }


`detailRepo` and `occupancyRepo` are injected but never referenced anywhere in this service — all DB access goes through `manager.getRepository(...)` / `this.dataSource.manager` (`submit`, `settleDate`, `findOccupanciesForDate`, `findAllOccupanciesForDate`). Remove the unused injections (and their `@InjectRepository` params) to eliminate dead code and unnecessary DI setup.

    @InjectRepository(RoomInspectionDetail)
    private readonly detailRepo: Repository<RoomInspectionDetail>,
    @InjectRepository(Room)
    private readonly roomRepo: Repository<Room>,
    @InjectRepository(Occupancy)
    private readonly occupancyRepo: Repository<Occupancy>,


The per-room loop in `settleDate` processes rooms strictly sequentially and also awaits `operationLogs.log` inside the loop. Since each transaction locks a different room row, the rooms are independent and could be settled in parallel with `Promise.all` (with a shared counter guarded by `created++` in the map stage), and the operation-log writes could be batched after all settlements. This matters when an outage backlog leaves many rooms to auto-settle at 00:05.

    for (const [roomId, roomOccupancies] of byRoom) {
      const result = await this.dataSource.transaction(async (manager) => {


Business status/source literals ('present'/'absent', 'manual'/'automatic') are hardcoded in several places in this file, duplicating the `RoomInspectionSource` type already exported by `room-inspection.entity.ts`. A typo in one literal would silently diverge from the entity's allowed values. Extract shared constants (or an enum) and reuse them in both `submit` and `settleDate`.

status: presentSet.has(occupancy.id) ? 'present' : 'absent',


Timezone inconsistency in date conversion: the Date is constructed from a UTC epoch (Date.UTC(1899,11,30) + serial*86400000), but formatDate reads local-timezone getters (getFullYear/getMonth/getDate). When the Excel serial contains a time-of-day component (e.g. a date cell with a timestamp), or when the server runs west of UTC, the resulting date can be off by ±1 day. Fix by formatting with UTC getters (getUTCFullYear/getUTCMonth/getUTCDate) to stay consistent with the UTC construction, or build the Date from a local-time epoch.

  const epoch = Date.UTC(1899, 11, 30);
  return formatDate(new Date(epoch + serial * 24 * 60 * 60 * 1000));


For date-typed columns, any text that does not match the YYYY-M-D pattern is silently passed through unchanged (e.g. '2024年9月1日', 'abc', or a mis-typed date). This means invalid/non-date values are imported into date fields (profileDate, startDate, examDate, recordDate) without any signal, and downstream logic expecting 'YYYY-MM-DD' may break or store garbage. Consider returning undefined for unmatched text (or explicitly normalizing more date formats) and validating at the import boundary.

  const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u);
  if (!match) return text;


When none of the named sheets are found, findWorksheet silently falls back to the first worksheet (index 0 for students). If a user uploads a workbook whose first sheet is not the student sheet (e.g. '报读班型' or '考试成绩' ordered first), rows will be parsed against the wrong column definitions and produce garbage data with no error. Consider validating that the fallback sheet's headers actually match the expected columns, or returning an explicit error when the expected sheet is missing.

return fallbackIndex === undefined ? undefined : workbook.worksheets[fallbackIndex];


Dead code: `AGENT_STUDENT_SELECT` is declared as the documented whitelist source of truth but is never referenced anywhere (both methods hard-code their own `.select([...])` arrays instead). This is misleading and a drift hazard — e.g. the search method's inline list adds `student.createdAt` while the constant omits it. Either use the constant in both methods or remove it to avoid a false sense that the whitelist is centralized/enforced.

  private static readonly AGENT_STUDENT_SELECT = [
    'student.id',
    'student.name',
    'student.studentNo',
    'student.gender',
    'student.status',
    'student.organizationId',
    'organization.name',
  ] as const;


User-supplied keyword is interpolated into a LIKE pattern without escaping `%`, `_`, or `\`. A keyword containing these characters acts as a wildcard (e.g. searching `%` matches every student, `a_` also matches `ab`), silently broadening the result set beyond the intended filter. Consider escaping the input before building the pattern (and use ESCAPE '\').

      const escaped = query.keyword.replace(/[\\%_]/g, (c) => `\\${c}`);
      qb.andWhere("(student.name LIKE :keyword OR student.student_no LIKE :keyword ESCAPE '\\')", {
        keyword: `%${escaped}%`,
      });

      qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', {
        keyword: `%${query.keyword}%`,
      });


The teacher-scope SQL fragment `cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)` is duplicated in `applyStudentScope` (as a join condition) and again in both the search and single-student classIds queries. If the scope logic ever changes (e.g. adding a role_type/status filter), these three copies must be updated in sync or the classIds returned to a teacher can drift out of the enforced scope. Consider extracting a shared helper/condition builder for the teacher scope.

    if (scope.type === 'teacher') {
      csQb.andWhere(
        'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
        { scopeTeacherUserId: scope.userId },
      );
    }


The business constant 'active' is hardcoded in both queries in this file (and mirrors the entity's `'active' | 'archived'` union). Per the hardcoding rule, extract it to a shared constant (e.g., `ORGANIZATION_STATUS.ACTIVE`) so the value is defined once and typos in the status string are caught at compile time.

where: { id, status: ORGANIZATION_STATUS.ACTIVE },


The entity has no unique constraint on `isHost`, so nothing prevents multiple active host organizations from existing. `findOne` without an explicit `order` then returns an arbitrary row, making the resolved "host" organization non-deterministic. Add an explicit `order` (e.g., `{ id: 'ASC' }`) for stable selection, or enforce single-host uniqueness at the database level.

where: { isHost: true, status: 'active' },
    order: { id: 'ASC' },


Non-string values (e.g., numbers/booleans returned by the Jinshuju API for number/date fields, such as a phone or student number stored as a numeric type) are silently dropped and become ''. This can cause silent data loss and missed phone/name matching in previewJinshuju/applyJinshuju without any error. Consider converting primitive values (e.g., `String(val).trim()` for non-null/undefined primitives) instead of discarding them, or at least logging a warning.

if (val === null || val === undefined) return '';
  return String(val).trim();

return typeof val === 'string' ? val.trim() : '';


Semantically, a missing rule (or a rule that belongs to another form) is not an HTTP 409 Conflict — this is a not-found/forbidden situation, and callers (previewJinshuju/applyJinshuju) pass a user-supplied ruleId. Additionally, the lookup is not scoped by formToken, so the rule is loaded first and then discarded. It would be simpler and more correct to query with `where: { id, formToken }` and throw `NotFoundException('规则不存在')` when not found, and use a more appropriate exception (e.g., ForbiddenException) for a token mismatch if the distinction is needed.

const rule = await repo.findOne({ where: { id } });
  if (!rule) throw new ConflictException('规则不存在');
  if (rule.formToken !== formToken) {
    throw new ConflictException('匹配规则不属于当前表单');
  }


`formToken.trim()` will throw a TypeError if `formToken` is `undefined`/`null` at runtime (TypeScript types don't protect against that at runtime, e.g. when called from request DTOs). Guard against null/undefined before calling trim.

if (!formToken?.trim()) throw new ConflictException('表单 Token 不能为空');

if (!formToken.trim()) throw new ConflictException('表单 Token 不能为空');


The bare `catch {}` swallows ALL errors thrown by `assertNoStudentReferences`, not just the `BadRequestException` raised when related data exists. Any unexpected failure (e.g., a transient DB/connection error during the 15 COUNT queries) is silently misreported as "存在关联数据" and the student is skipped with a misleading message. Catch only `BadRequestException` and rethrow other errors so real failures surface.

      try {
        await this.assertNoStudentReferences(student.id);
      } catch (err) {
        if (err instanceof BadRequestException) {
          skipped.push(`${student.name}（存在关联数据）`);
          continue;
        }
        throw err;
      }

      try {
        await this.assertNoStudentReferences(student.id);
      } catch {
        skipped.push(`${student.name}（存在关联数据）`);
        continue;
      }


The ID normalization/validation logic (`uniqueIds`, `Number.isInteger` check, existence check) is duplicated verbatim between `batchPurge` and `batchRestore`. Extract a private helper (e.g., `resolveStudents(ids): Promise<Student[]>`) that dedupes, validates positive integers, throws `BadRequestException` for invalid input and `NotFoundException` for missing students, then reuse it in both methods to keep the logic consistent.

    const uniqueIds = [...new Set(ids || [])];
    if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的学生');
    if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
      throw new BadRequestException('学生 ID 无效');
    }
    const students = await this.repo.find({ where: { id: In(uniqueIds) } });
    if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在');


The status values 'archived'/'active' are hardcoded in `batchRemove`, `restore`, `purge`, `batchPurge`, and `batchRestore`. These are business-critical state values; define shared constants (or a status enum) so typos/mismatched strings in future changes are caught at compile time.


`studentIds.length` will throw a TypeError if the method is ever invoked with `undefined`/`null` (e.g., a malformed request payload). Guard with `studentIds?.length` or default `studentIds || []` before accessing `.length`.

  async getArchiveExportMaps(studentIds: number[] = []) {
    if (studentIds.length === 0) {

  async getArchiveExportMaps(studentIds: number[]) {
    if (studentIds.length === 0) {


Deletion of each student is awaited sequentially in a loop, but the per-student delete operations are independent of each other. For large batches this serializes N round-trips to the DB; use `Promise.all` with per-student `catch` to keep the skip semantics while running deletes concurrently.

      await this.repo.delete(student.id);
      deleted.push(student.id);


Race condition / non-atomic status check: `batchRemove` reads `s.status` here and then bulk-updates with `WHERE id IN (...)` without re-asserting `status != 'archived'`. Two concurrent `batchRemove` calls (or one `batchRemove` racing with `restore`) can both count the same student as affected, producing inflated/incorrect result counts. Add the status predicate to the UPDATE (e.g., `.where('id IN (:...ids) AND status != :archived', { ids: targetIds, archived: 'archived' })`) so `result.affected` reflects only students actually transitioned.

    for (const s of students) {
      if (s.status === 'archived') skipped.push(s.name);
      else targetIds.push(s.id);
    }


Contradictory validation rules make `dateFrom` impossible to validate successfully. `@Matches(/^\d{4}-\d{2}-\d{2}$/)` only accepts a bare `YYYY-MM-DD` date, but `@IsISO8601({ strict: true })` (validator.js strict mode) requires a full ISO 8601 timestamp with the `T` separator, time portion, and optional timezone (e.g. `2026-08-09T00:00:00.000Z`). Since class-validator requires all constraints on a property to pass, no value can ever satisfy both — any request that includes `dateFrom` will be rejected with a 400, making the query parameter unusable. Fix by picking one format: drop `strict: true` so `@IsISO8601()` accepts plain dates, or align the regex to the strict ISO format.

  @Matches(/^\d{4}-\d{2}-\d{2}$/)
  @IsISO8601({ strict: true })
  dateFrom?: string;


This module registers 19 entities spanning many unrelated domains (attendance, finance: Bill/Deposit/PersonalExpense, room inspection, exams, archives, occupancy, wallet) in a single TypeOrmModule.forFeature block. This turns StudentsModule into a "god module" with very broad coupling: every consumer that imports StudentsModule (e.g. agent-tools) transitively pulls in repository metadata for all these domains, and StudentsService grows to aggregate lifecycle/import/agent concerns. Consider splitting into cohesive sub-modules (e.g. student core, student lifecycle/archive, student finance) and importing them here, so entity registration and providers stay scoped to their actual domain.

    TypeOrmModule.forFeature([
      Student,
      Class,
      ClassStudent,
      AttendanceRecord,
      Organization,
      ClassTeacher,
      StudentProfile,
      StudentEnrollment,
      ExamScore,
      LearningRecord,
      ResultArchive,
      Occupancy,
      PersonalExpense,
      Bill,
      Deposit,
      ArchiveAttachment,
      StudentDingMapping,
      StudentWallet,
      RoomInspectionDetail,
    ]),


`limit` is passed straight to TypeORM `take` with no upper bound (and no lower-bound check either — 0 or negative values are accepted by ParseIntPipe). A client can request an arbitrarily large limit (e.g. `limit=9999999`), causing an oversized DB query and memory pressure. Clamp the value, e.g. `Math.min(Math.max(limit ?? 50, 1), 200)`, and validate before passing to the service.

    const safeLimit = Math.min(Math.max(limit ?? 50, 1), 200);
    return this.syncService.getLogs(platform, safeLimit);

return this.syncService.getLogs(platform, limit ?? 50);


`platform` is typed as `SyncPlatform` but is never validated at runtime. In `syncService.triggerSync`, any value that is not one of the three known platforms (e.g. a typo like `dingtalk_student`) silently falls through to the final branch and triggers a FULL sync of all three platforms (students + attendance + wecom) — an expensive and surprising side effect for a mistyped query param. Validate `platform` against the allowed enum values and throw `BadRequestException` for unknown values.

    if (platform && !['dingtalk_students', 'dingtalk_attendance', 'wecom'].includes(platform)) {
      throw new BadRequestException(`unknown platform: ${platform}`);
    }
    const logs = await this.syncService.triggerSync(
      platform,
      rootId,
      createMissing !== 'false',
      updateProfile !== 'false',
    );


Only the array non-emptiness of `decisions` is checked; each item is not validated. `action` is only typed as `'match' | 'create' | 'skip'` at compile time, but a malformed request (e.g. `action: 'delete'`, or `action: 'match'` without `matchStudentId`) passes straight through to `syncService.applyJinshuju`, where such entries are silently skipped with no error surfaced to the user, and `createName`/`createPhone` are unconstrained strings. Validate each decision item (action whitelist, required `serialNumber`, conditional `matchStudentId`/`createName`) and return a 400 with a clear message.

    if (!Array.isArray(body.decisions) || body.decisions.length === 0) {
      throw new BadRequestException('decisions 不能为空');
    }
    for (const d of body.decisions) {
      if (typeof d.serialNumber !== 'number' || !['match', 'create', 'skip'].includes(d.action)) {
        throw new BadRequestException('decisions 包含无效项');
      }
      if (d.action === 'match' && !d.matchStudentId) {
        throw new BadRequestException('match 操作需要 matchStudentId');
      }
    }


The default root department id `1` is a business-specific magic number hardcoded here (and repeated in the two org-tree endpoints below). If the DingTalk root department changes, it must be updated in several places. Consider extracting it to a named constant/config value so the default is defined once.

const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;


Authorization gap: unlike `findAll` and `getFilterLookups`, this method accepts no `accessibleClassIds` and the controller (`GET /students/basic-lookups`, permission `student:basic-view`/`student:view`) calls it without class scoping. As a result, any teacher who can view students gets every active student's PII (phone, studentNo, gender) across all classes — a horizontal privilege escalation inconsistent with the class-scoped behavior elsewhere in this file. Suggest accepting `accessibleClassIds` and filtering via `classStudentRepo` (same as `findAll`), or restricting this endpoint to manage-all users.

  async getBasicLookups(accessibleClassIds?: number[]) {
    const where: FindOptionsWhere<Student> = { status: 'active' };
    if (accessibleClassIds) {
      const classStudents = await this.classStudentRepo.find({
        where: { classId: In(accessibleClassIds), status: 'active' },
      });
      where.id = In([...new Set(classStudents.map((item) => item.studentId))]);
    }
    return this.repo.find({
      select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'],
      where,
      order: { name: 'ASC' },
    });
  }

  async getBasicLookups() {
    return this.repo.find({
      select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'],
      where: { status: 'active' },
      order: { name: 'ASC' },
    });
  }


User-supplied `%` and `_` characters are not escaped in the `Like` pattern, so searching for e.g. `100%` or `a_b` matches many more rows than intended (wildcard injection). The leading wildcard also defeats index usage on the name column. Escape `\`, `%`, `_` before building the pattern and pass the escape char to TypeORM.

    if (query?.name) {
      const escaped = query.name.replace(/[\\%_]/g, '\\$&');
      where.name = Like(`%${escaped}%`, '\\');
    }

if (query?.name) where.name = Like(`%${query.name}%`);


Business status values are hardcoded as magic strings here ('正常', '缺勤', '迟到', '请假' and their English aliases). These are duplicated inline and easy to get out of sync with the AttendanceRecord entity/enum values. Extract the canonical status set into a shared constant/enum and map legacy Chinese values through it.

      const present = records.filter((r) => r.status === 'present' || r.status === '正常').length;
      const absent = records.filter((r) => r.status === 'absent' || r.status === '缺勤').length;
      const late = records.filter((r) => r.status === 'late' || r.status === '迟到').length;
      const leave = records.filter((r) => r.status === 'leave' || r.status === '请假').length;


The child services (StudentsImportService, StudentsLifecycleService, StudentsAgentService) are manually instantiated with positionally wired repository arguments via lazy getters, bypassing Nest's DI container. This is brittle (any constructor reorder/extension silently fails or requires touching this file) and these instances miss DI-provided features. Prefer declaring them as providers and injecting (use forwardRef if needed to break circular deps), or at minimum document why manual composition is required here.

  private get imports(): StudentsImportService {
    if (!this.importService) {
      this.importService = new StudentsImportService(


Attendance group name is derived only from className. If two different classes share the same name (very common in schools, e.g. "高一(1)班" in different grades/years), the second class will hit the group cached under the same name and `updateAttendanceGroup` will overwrite the group's members and shift_ids with the second class's data. The first class's already-written schedule items then point to a group whose members were replaced, corrupting attendance data. Unlike shift names, the group name has no period component to disambiguate. Suggest appending the classId: `排课_${className}_${classId}`.

      // 创建/匹配该班级的考勤组
      const groupName = `排课_${className}_${classId}`;

      // 创建/匹配该班级的考勤组
      const groupName = `排课_${className}`;


This `!=` comparison violates the project's strict-equality rule (== and != are prohibited). Use `!== null && !== undefined`, or explicitly handle null with a typed guard.

      where: { status: 'active' },
    });
    const schedules = allSchedules.filter((s) => s.classId !== null && s.classId !== undefined);

      where: { status: 'active' },
    });
    const schedules = allSchedules.filter((s) => s.classId != null);


Same `!=` issue in `getStatus` (identical filter copied from `syncAll`). The active-schedule query + classId extraction is duplicated in both methods; consider extracting a small private helper to avoid drift.

    const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } });
    const schedules = allSchedules.filter((s) => s.classId !== null && s.classId !== undefined);

    const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } });
    const schedules = allSchedules.filter((s) => s.classId != null);


The docstring here describes `buildClassDingUserMap` (copy-pasted), not `getStatus` itself, and the `_targetDate` parameter is unused. Either remove the parameter, use it, or fix the comment to describe the actual behavior of this method.

  /**
   * 构建 classId → 学生钉钉 userId 列表。
   * 一次性查询所有班级的活跃学生与钉钉映射，避免 N+1。
   */
  async getStatus(_targetDate: string): Promise<{


`opUserId = 'manager'` is a hardcoded business value as the default DingTalk operator. If the actual operator's userId is not exactly "manager", every create/update call (shifts, groups, scheduleUsers) will fail. Consider injecting this via configuration/DI instead of a magic default.


The per-unique-shift `upsertShift` calls are executed strictly sequentially (one network round-trip each). For many classes × time-slot combinations this adds up. Since each shift name is unique, these are independent operations and could be run with bounded-concurrency `Promise.all` to reduce total sync time (while still respecting DingTalk rate limits).

for (const [key, { className, periods }] of uniqueShifts) {


Errors thrown by `releaseLease` in the `finally` block will mask the original result: if the operation succeeded (and the log/state were updated) but releasing the lease fails (e.g. a transient DB error), the caller receives an exception for a fully successful sync, and the lease stays held until it goes stale. Wrap the release in a try/catch and only log the failure instead of rethrowing.

    } finally {
      try {
        await this.releaseLease(platform, runId);
      } catch (releaseError) {
        this.logger.error(`Failed to release lease for ${platform}: ${releaseError}`);
      }
    }

    } finally {
      await this.releaseLease(platform, runId);
    }


If `finishSyncLog` itself throws here (e.g. DB unavailable), the original sync error is replaced by the `finishSyncLog` error, losing the root cause and leaving the log stuck in 'running'. Wrap the failure-log write in try/catch and always rethrow the original `error`.

      if (log) {
        try {
          await this.finishSyncLog(log, 'failed', 0, message);
        } catch (finishError) {
          this.logger.error(`Failed to mark sync log failed for ${platform}: ${finishError}`);
        }
      }
      this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined);
      throw error;

      if (log) await this.finishSyncLog(log, 'failed', 0, message);
      this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined);
      throw error;


The `lastSyncAt` update is not scoped to the acquired lease's `runId`. In a stale-lease takeover (process A runs longer than LEASE_MS, process B takes over), A can finish later and overwrite B's `lastSyncAt` with its own completion time, corrupting the incremental-sync watermark. Scope the update with `AND run_id = :runId` (same as `releaseLease` does) so only the lease holder can advance the watermark.

      await this.syncStateRepo
        .createQueryBuilder()
        .update()
        .set({ lastSyncAt: new Date() })
        .where('platform = :platform AND run_id = :runId', { platform, runId })
        .execute();

await this.syncStateRepo.update({ platform }, { lastSyncAt: new Date() });


The lease has a fixed 30-minute timeout with no renewal/heartbeat. If the sync operation legitimately runs longer than `LEASE_MS` (common for large full syncs), another instance can acquire the lease and start a concurrent run of the same platform, causing duplicate imports and conflicting watermark updates. Make the lease duration configurable (e.g. via ConfigService) and/or refresh `runningSince` during long-running operations so a live run is not treated as stale.


Lease staleness is judged by comparing the DB column `running_since` against app-server time (`new Date()`). If the application server clock drifts from the database clock, leases can be considered stale too early (concurrent runs) or too late (dead runs block for longer than expected). Consider comparing against the DB clock (`NOW()`/`CURRENT_TIMESTAMP`) or normalizing both sides to a single time source.

      .set({ runId, runningSince: new Date() })
      .where('platform = :platform', { platform })
      .andWhere('(running_since IS NULL OR running_since < :staleBefore)', {
        staleBefore: new Date(Date.now() - LEASE_MS),
      })


`studentId` is an optional query parameter typed as `string`, so when it is missing/invalid `Number(undefined)` yields `NaN`. The service then runs `transactionRepo.find({ where: { studentId: NaN } })`, silently returning an empty list instead of a meaningful 400 error. Validate the parameter explicitly, e.g. `@Query('studentId', ParseIntPipe) studentId: number` and pass it directly, so a missing/non-numeric value is rejected by Nest's validation pipeline.

  findTransactions(@Query('studentId', ParseIntPipe) studentId: number) {
    return this.service.findTransactions(studentId);
  }

  findTransactions(@Query('studentId') studentId: string) {
    return this.service.findTransactions(Number(studentId));
  }


The wallet balance change (a DB transaction) is committed before `logService.log` is awaited. If the audit-log write fails, the exception propagates to the client as an error even though the balance change already succeeded — a retry from the client (without a matching `operationId`) would double-charge the student, and the audit trail for a successful money operation would be lost. Consider wrapping the log call in try/catch (logging failure independently, e.g. via the logger) so the response reflects the actual committed state, or record the log inside the same transaction as the balance change. Note that failed operations are also not logged at all here, which may matter for audit completeness.

    const result = await this.service.changeBalance(dto, req.user?.id);
    const { ipAddress, userAgent } = extractRequestInfo(req);
    await this.logService.log({


The logging logic (`extractRequestInfo` + `logService.log` with the same module/targetType and nearly identical detail format) is duplicated verbatim between `changeBalance` and `batchChangeBalance`. Extract it into a small private helper (e.g. `logBalanceChange(req, action, targetId, detail)`) to avoid divergence when the log schema changes.

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


This DTO duplicates operationId/amount/type/description from ChangeWalletBalanceDto almost verbatim. Extract a shared base class (e.g., BaseWalletBalanceChangeDto) with these common fields and have both DTOs extend it, keeping only studentId / studentIds in each subclass.

export class BatchChangeWalletBalanceDto {
  @IsOptional()
  @IsString()
  @Matches(/^[\w-]{8,64}$/)
  operationId?: string;


studentIds is explicitly transformed with @Type(() => Number), but amount is not. If a client sends amount as a JSON string (e.g. "10.5", common when amounts come from forms or spreadsheets) and global implicit conversion is not enabled, IsNumber will reject it while studentIds would still be accepted. Apply @Type(() => Number) to amount in both DTOs for consistent, predictable transformation.

  @Type(() => Number)
  @IsNumber({ maxDecimalPlaces: 2 })
  @NotEquals(0)
  amount: number;

  @IsInt({ each: true })
  @Type(() => Number)
  studentIds: number[];

  @IsNumber({ maxDecimalPlaces: 2 })
  @NotEquals(0)
  amount: number;


studentIds has no upper size bound. A malicious client can submit an arbitrarily large array, causing excessive validation CPU/memory usage and a huge batch operation (resource-exhaustion / DoS risk). Add @ArrayMaxSize (e.g. 1000) and import it from class-validator.

  @IsArray()
  @ArrayNotEmpty()
  @ArrayMaxSize(1000)
  @IsInt({ each: true })
  @Type(() => Number)
  studentIds: number[];

  @IsArray()
  @ArrayNotEmpty()
  @IsInt({ each: true })
  @Type(() => Number)
  studentIds: number[];


Storing wallet amounts as a JS number (IEEE-754 float64) is risky for financial operations: values like 0.1 + 0.2 produce 0.30000000000000004, and @IsNumber({ maxDecimalPlaces: 2 }) also fails to catch such derived values (and misses exponential forms like 1e-7). Consider representing amounts in integer minor units (cents) or using a decimal library, keeping this decorator only as a validation layer.

  @IsNumber({ maxDecimalPlaces: 2 })
  @NotEquals(0)
  amount: number;


The `Occupancy` entity is registered in `TypeOrmModule.forFeature` but no provider in this module ever injects an `Occupancy` repository — `WalletsService` only references it indirectly via relation joins (`student.occupancies` / `room.occupancies`), which work off the parent entity metadata, not module-scoped repository registration. This is dead registration (and an unused import). Remove `Occupancy` from the `forFeature` list unless an `Occupancy` repository is actually injected here.

imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill, Room]), OperationLogsModule],

imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill, Room, Occupancy]), OperationLogsModule],


When a Jinshuju entry has all mapped fields empty, `mappedValues` becomes `{}` and `manager.update(Student, decision.matchStudentId, {})` throws TypeORM's `UpdateValuesMissingError` ("Cannot perform update query because update values are not defined"), which rolls back the entire jinshuju sync transaction and fails the whole sync. Guard the update with a non-empty check (or skip the entry) before calling `update`.

          if (decision.action === 'match' && decision.matchStudentId) {
            if (Object.keys(mappedValues).length > 0) {
              await manager.update(Student, decision.matchStudentId, mappedValues);
              matched++;
            }

          if (decision.action === 'match' && decision.matchStudentId) {
            await manager.update(Student, decision.matchStudentId, mappedValues);
            matched++;


`LEASE_MS` is declared but never used in this file — `SyncRunner` already defines its own module-level `LEASE_MS` for lease staleness. This is dead code and should be removed to avoid confusion about which constant actually controls the lease.

private static readonly LEASE_MS = 30 * 60 * 1000;


Nested ternary expressions are not allowed per the review rules. This message-building chain is hard to read; extract it into an if/else helper (or a small local function) to keep the logic flat and readable.

        message: (() => {
          if (result.conflicts.length) return JSON.stringify(result.conflicts.slice(0, 20));
          if (!createMissing && !updateProfile) return `手机号绑定 ${result.matched ?? 0} 人，跳过 ${result.skipped ?? 0} 人`;
          if (createMissing) return `新增 ${result.created} 人，更新 ${result.updated} 人，手机号绑定 ${result.matched ?? 0} 人`;
          return `手机号绑定 ${result.matched ?? 0} 人，更新 ${result.updated} 人，跳过 ${result.skipped ?? 0} 人`;
        })()

        message: result.conflicts.length
          ? JSON.stringify(result.conflicts.slice(0, 20))
          : !createMissing && !updateProfile
            ? `手机号绑定 ${result.matched ?? 0} 人，跳过 ${result.skipped ?? 0} 人`
            : createMissing
            ? `新增 ${result.created} 人，更新 ${result.updated} 人，手机号绑定 ${result.matched ?? 0} 人`
            : `手机号绑定 ${result.matched ?? 0} 人，更新 ${result.updated} 人，跳过 ${result.skipped ?? 0} 人`,


These three syncs are independent (different platforms and separate leases), but they run sequentially and `SyncRunner.run` rethrows on failure — so if `syncDingTalkStudents` fails, attendance and WeCom syncs are silently skipped for this trigger. Consider isolating failures (e.g., `Promise.all`/`allSettled` or try/catch per sync) so one platform's failure doesn't block the others.

    return [
      await this.syncDingTalkStudents(rootDeptId, createMissing, updateProfile),
      await this.syncDingTalkAttendance(),
      await this.syncWeCom(),
    ];


Loose equality `==` is used here, which violates the project's strict-equality rule. Since `classId` is typed `number | null`, `=== null` is sufficient and equivalent here.

if (schedule.classId === null || schedule.weekDay !== weekDay) continue;

if (schedule.classId == null || schedule.weekDay !== weekDay) continue;


`new Date('...T00:00:00.000Z')` on a malformed `syncFrom`/`syncTo` (e.g., user-supplied `dateFrom`) produces an Invalid Date, and the loop condition `date <= toDate` silently evaluates to false — the function returns an empty plan list with no error or log, masking configuration mistakes. Consider validating the input date format (e.g., `/^\d{4}-\d{2}-\d{2}$/` and `Number.isNaN(fromDate.getTime())`) and throwing/logging a clear error instead of failing silently.

  const fromDate = new Date(`${syncFrom}T00:00:00.000Z`);
  const toDate = new Date(`${syncTo}T00:00:00.000Z`);

  for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {


No validation of the `HH:mm` input: a malformed or empty time yields `NaN` for hour/minute, which silently propagates into downstream shift parameters (`across` flag, `absenteeism_late_minutes` via `minutesBetween`, and schedule `work_date`), corrupting DingTalk sync data. Validate the format (e.g., `/^\d{1,2}:\d{2}$/`) or handle NaN explicitly.

export function toMinutes(time: string): number {
  const [hour, minute] = time.split(':').map(Number);
  return hour * 60 + minute;
}


This is an O(days × schedules) nested loop: for every date in the range it re-scans all schedules and re-formats the date string. For long ranges (e.g., a year, 365 iterations) with many active schedules this adds avoidable CPU/GC pressure. Consider iterating schedules once and generating their applicable dates (or pre-indexing schedules by `weekDay`/date bounds) instead.

  for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {
    const dateStr = dayjs(date).utcOffset(8).format('YYYY-MM-DD');
    const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay();

    for (const schedule of schedules) {


`In(classIds)` / `In(studentIds)` builds an unbounded SQL `IN` list. When a sync covers many classes/students (thousands of ids), this can exceed the DB's parameter limit (e.g., SQLite's variable limit or MySQL's max packet) and degrade query performance. Consider chunking the id lists (e.g., batches of 500–1000) or querying by a JOIN.

  const links = await classStudentRepo.find({
    where: { classId: In(classIds), status: 'active' },
  });


This function applies the single `dingUserIds` list to every plan in `plans`. It is only correct because the current caller passes per-class plan subsets; if `plans` ever contains multiple classes, students from one class would be assigned schedule items for other classes' dates. Make the grouping explicit (e.g., take a `classId` parameter or a per-plan user list) or document the precondition in a comment.

export function expandDailySchedulePlans(
  plans: DailySchedulePlan[],
  dingUserIds: string[],
  planToShiftId: Map<string, number>,
): DingTalkScheduleItem[] {


Concurrency / double-spend risk: `debitBill` (and `refundBill`/`settleOutstandingBills`) read `wallet.balance` with `getOrCreateWallet(manager, bill.studentId)` (lock defaults to false → plain `findOne`, no `FOR UPDATE`). External callers (`bills.service.ts`, `bills-generation.service.ts`) invoke `debitBill` inside their own transactions without ever locking the wallet row. In MySQL/InnoDB a plain SELECT is a non-blocking consistent read, so two concurrent debits for the same student (e.g. two bills generated in parallel, or a debit racing a wallet adjustment) can both read the same balance and both apply it → lost update / balance below zero / bill marked paid on money already spent. Since all callers pass a transactional `EntityManager`, read the wallet with `setLock('pessimistic_write')` (pass lock=true) in `debitBill`/`refundBill`, or use an atomic conditional update (`UPDATE student_wallets SET balance = balance - x WHERE student_id = ? AND balance >= x`).

    const wallet = await this.getOrCreateWallet(manager, bill.studentId, true);
    const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining));

    const wallet = await this.getOrCreateWallet(manager, bill.studentId);
    const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining));


`operationId` from the DTO is validated as `^[\w-]{8,64}$` (up to 64 chars), but the batch path stores `${operationId}:${studentId}` into `wallet_transactions.operation_id`, which is `varchar(64)`. For a long base operationId the suffixed value overflows the column: in strict SQL mode the insert fails and rolls back the whole batch transaction; in non-strict mode it is silently truncated, corrupting the stored id and breaking audit/idempotency lookups. Consider storing the base operationId without the student suffix (the batch transaction row already identifies the operation), or widen/limit the column.

operationId ? `${operationId}:${studentId}` : undefined,


The bare `catch {}` swallows every database error (connection loss, unrelated constraint violations, etc.), not just the duplicate-row race the comment assumes; execution then continues and, if the wallet still can't be found, surfaces a misleading '学生钱包创建失败' instead of the real failure. Additionally `manager.insert` always exists on `EntityManager`, so the `else` branch is dead code. Prefer an idempotent insert (`INSERT ... ON DUPLICATE KEY UPDATE` / `orIgnore()`) followed by a re-query, or narrow the catch to duplicate-key errors and rethrow anything else.

      } catch {
        // A concurrent request may have inserted the one wallet row.
      }


`findTransactions` loads the student's entire transaction history with no pagination or limit. `wallet_transactions` grows indefinitely (every recharge/adjustment/bill payment/refund adds a row), so this endpoint's latency and memory usage will degrade over time. Add take/skip or cursor pagination, or at least a `take` cap.

return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });


Log paths are relative, and PM2 resolves relative `out_file`/`error_file` paths against the working directory where the `pm2` command is launched — not against the app's `cwd` (DEPLOY_DIR). Since this config explicitly supports being started from any directory via DEPLOY_DIR, logs may be written to unexpected locations (or the write fails if the directory doesn't exist there), making them hard for monitoring/rotation to find. Use absolute paths built from DEPLOY_DIR, e.g. `path.join(DEPLOY_DIR, 'logs/backend-error.log')` (require('path') at the top).

      error_file: 'logs/backend-error.log',
      out_file: 'logs/backend-out.log',


No `kill_timeout` is configured. On `pm2 startOrReload` (and restarts), PM2 sends SIGINT and then SIGKILL after a short default grace period (~1.6s), which can abort in-flight HTTP requests and cut DB connections before the Nest app finishes graceful shutdown. Consider adding `kill_timeout: 5000` (and optionally `listen_timeout`) and ensure the app handles SIGINT to drain connections cleanly during deploys.

      autorestart: true,
      watch: false,


`API_TARGET` is read from the environment but never actually used — the proxy hardcodes `127.0.0.1:3000` (hostname, port, and host header are all hardcoded in three places). If `API_TARGET` points to a different host/port, requests are still forwarded to 127.0.0.1:3000, silently breaking the configuration. Parse the target once with `new URL(API_TARGET)` and derive hostname/port/host from it (note `http.request` also can't handle an `https://` target).

    const target = new URL(API_TARGET);
    const opts = {
      hostname: target.hostname,
      port: target.port || (target.protocol === 'https:' ? 443 : 80),
      path: (target.pathname === '/' ? '' : target.pathname) + req.url,
      method: req.method,
      headers: { ...req.headers, host: target.host },
    };

    const opts = {
      hostname: '127.0.0.1',
      port: 3000,
      path: req.url,
      method: req.method,
      headers: { ...req.headers, host: '127.0.0.1:3000' },
    };


The catch-all fallback swallows every read error (ENOENT, EACCES, etc.) and returns `index.html` with a 200 for ANY missing file — including missing `.js`/`.css` assets. This serves HTML where a script module was expected (breaking module loading and masking real deployment problems) and never produces a 404. Worse, if `apps/admin/dist/index.html` itself is missing, `fs.readFileSync` throws inside the catch block, propagates out of the request handler, and crashes the process. Only fall back for navigation requests (no file extension / `Accept: text/html`) and return a proper 404 for missing assets; guard the index.html read as well.

  } catch {
    // SPA fallback: return index.html
    const index = fs.readFileSync(path.join(STATIC_DIR, 'index.html'));
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(index);
  }


`fs.readFileSync` blocks the Node event loop on every request. Under load, all concurrent requests are serialized behind synchronous file I/O, and large files are fully buffered in memory. Use async `fs.promises.readFile` or stream the file with `fs.createReadStream`.

    const stream = fs.createReadStream(filePath);
    res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' });
    stream.pipe(res);

    const content = fs.readFileSync(filePath);
    res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' });


Two proxy robustness issues: (1) `proxyRes.headers` is forwarded verbatim, including hop-by-hop headers (`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, …). Because the response is piped, Node sets its own `transfer-encoding`/`content-length`, which can conflict with the forwarded headers and cause protocol errors or truncated responses — strip hop-by-hop headers before `writeHead`. (2) `proxy.on('error')` can fire after headers have already been written (mid-stream upstream failure or client disconnect), so the `res.writeHead(502)` call will throw uncaught; guard with `res.headersSent`, add a timeout for the upstream connection, and handle errors from `req.pipe(proxy)` / `proxyRes.pipe(res)`.

    const proxy = http.request(opts, (proxyRes) => {
      res.writeHead(proxyRes.statusCode, proxyRes.headers);
      proxyRes.pipe(res);
    });


Because every request URL is absolute (starts with `/`), `path.normalize` already clips leading `..` at the root, so the `replace(/^((\.\.(\/|\\|$))+)/, '')` regex never matches anything — it's misleading dead code. Percent-encoded traversal (`%2e%2e%2f`) is neither decoded nor checked, and `path.join`/`normalize` semantics are platform-dependent. Decode the URL with `decodeURIComponent` and explicitly verify the resolved path stays inside `STATIC_DIR` (e.g., `path.resolve(...)` and `startsWith(STATIC_DIR + path.sep)`), returning 403/404 otherwise; this also fixes serving filenames containing spaces/encoded characters.

  const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
  const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, '');
  serveStatic(res, path.join(STATIC_DIR, safePath));


密码以 `-p"${MYSQL_PASS}"` 形式拼在命令行参数中，会出现在 `ps aux` 进程列表里，同机其他用户可直接看到明文密码。建议改用 `docker compose exec -T -e MYSQL_PWD="${MYSQL_PASS}" mysql mysql -u root ...` 方式传入，避免密码暴露在进程列表中。

gunzip -c "$DUMP" | docker compose exec -T -e MYSQL_PWD="${MYSQL_PASS}" mysql mysql -u root dorm_billing

gunzip -c "$DUMP" | docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" dorm_billing


硬编码默认密码 `gongxue_2024` 作为 `.env` 缺失时的回退值。该凭据一旦随代码库泄露即等于泄露，且会静默使用弱口令/已知口令部署。建议强制要求 MYSQL_ROOT_PASSWORD 从 `.env` 或环境变量注入，缺失时直接报错退出，而不是回退到默认值。


脚本最后把含明文密码的完整命令 `-p${MYSQL_PASS}` echo 到终端/日志，直接泄露凭据（也会进入 CI/终端历史）。建议只输出不含密码的验证命令，例如提示使用 MYSQL_PWD 或提示输入密码。

echo "  docker compose exec -e MYSQL_PWD=<password> mysql mysql -u root gongxue -e 'SELECT COUNT(*) FROM students'"

echo "  docker compose exec mysql mysql -u root -p${MYSQL_PASS} gongxue -e 'SELECT COUNT(*) FROM students'"


固定 `sleep 8` 无法保证 TypeORM synchronize 已完成建表：机器较慢或应用启动耗时较长时，脚本会提前进入步骤 3，导致 migrate-legacy.sql 因目标表不存在而失败（且失败后处于不一致状态）。建议改为轮询等待就绪条件（如循环检查目标表 `gongxue.students` 是否存在，或等待应用健康检查通过）再继续。


`migrate-legacy.sql` 通过 stdin 直接执行，未包裹在事务中；若中途失败，数据将部分迁移、库处于不一致状态，且脚本没有备份/回滚机制。建议在 SQL 内显式使用 `START TRANSACTION ... COMMIT`（确认目标表为 InnoDB），或迁移前先备份目标库。

docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" < migrate-legacy.sql


脚本没有 trap 清理逻辑：若在 `pm2 start` 之后、`pm2 stop` 之前任一步失败，`set -e` 直接退出会留下运行中的应用；反之在 `pm2 stop` 后失败，应用会一直处于停止状态。另外，若 backend 已在运行（非严格首次运行场景），`pm2 start` 会因重复启动失败直接终止脚本。建议用 trap 在失败时恢复应用状态，并在 start 前先检测/处理已运行实例。

DB_SYNCHRONIZE=true pm2 start ecosystem.config.cjs --only gongxue-backend


`migrate-legacy.sql` 使用相对路径，脚本从其他工作目录执行时会找不到文件。建议在脚本开头 `cd "$(dirname "$0")"` 或使用相对脚本目录的绝对路径，保证脚本在任意目录下可运行。


Remote commands run without `set -e` (only the local shell enables `set -euo pipefail`). The ssh exit code is determined solely by the last remote command (`pm2 status`), so if `npm ci` or `npm run migration:run -w @gongxue/server` fails, the script still reloads PM2 and prints "部署完成！" — a failed migration is silently swallowed and the deploy is reported as successful. Add `set -euo pipefail` at the start of the remote command string (right after `cd ${REMOTE_DIR}`) or check each step's exit code explicitly.

  echo '执行数据库迁移...'
  npm run migration:run -w @gongxue/server
  echo 'PM2 重载...'
  pm2 startOrReload ecosystem.config.cjs --update-env
  pm2 save
  pm2 status


`--delete` will remove any server-only files that are not excluded. The repo contains no committed `.env` (only `.env.example`), so the production `.env` created manually on the server would be deleted on every deploy, and a local dev `.env` (if present) would overwrite the production one — leaking/erasing secrets. Add `--exclude='.env'` / `--exclude='.env.*'` and exclude any other server-only runtime data (e.g. `uploads/`) that must survive deploys.

rsync -avz --delete \
  --exclude='node_modules' \
  --exclude='.git' \
  --exclude='*.db' \
  --exclude='.DS_Store' \
  --exclude='logs/' \
  --exclude='.turbo/' \
  ./ "${SSH_HOST}:${REMOTE_DIR}/"


`npm ci` runs only when `node_modules` does not exist on the server. On subsequent deploys, changes to `package.json`/`package-lock.json` (new or removed dependencies) are never installed because `node_modules` is excluded from rsync and already present remotely — production can run with stale dependencies while the local build (used to produce the deployed bundle) was built with new ones. Always run `npm ci --omit=dev` on the server, or trigger it by comparing the remote and local `package-lock.json` checksums.

  if [ ! -d node_modules ]; then
    echo '首次部署，安装依赖...'
    npm ci --omit=dev
  fi


The fallback `|| curl -s ifconfig.me` is ineffective: `hostname -I 2>/dev/null | awk "{print $1}"` is a pipeline whose exit status is that of `awk`, which still exits 0 (with empty output) when `hostname -I` fails. The script then prints `http://` with no host. Capture the IP into a variable, validate it is non-empty, and only then fall back to curl.

echo "访问: http://$(ssh "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')"


The React version is hardcoded to '19.0.0' with no match found in any package.json in this repo. This setting can silently drift from the actual dependency version, causing react-plugin rules to evaluate against a stale version. Prefer `version: 'detect'` (auto-detect from package.json) or keep this value in sync with the real dependency.


Globally disabling `typescript/no-explicit-any` removes the type-safety guardrail for the entire codebase, contradicting the project's TypeScript quality requirement of avoiding `any`. If `any` is only needed in a few places, keep the rule enabled and use targeted `// eslint-disable` comments (or annotate the `any` usage with a justification) instead of turning it off globally.


`--delete` will remove any files on the server that are not present in the source tree. Runtime config such as `.env` / `.env.*` (typically gitignored and thus absent from the repo) will be silently deleted on every deploy, breaking DB credentials and other environment settings. Add `--exclude='.env'` (and `--exclude='.env.*'`) or keep server-side config outside REMOTE_DIR.

          rsync -avz --delete \
            --exclude='node_modules' \
            --exclude='.git' \
            --exclude='*.db' \


Dependencies are only installed when `node_modules` does not exist. On subsequent deploys, changes to `package.json` / `package-lock.json` will never be installed, so the server keeps running stale dependency versions after the code has been updated. Run `npm ci --omit=dev` unconditionally (or compare a lockfile hash / mtime) instead of gating on directory existence.

            if [ ! -d node_modules ]; then
              echo '首次部署，安装生产依赖...'
              npm ci --omit=dev
            fi


No `concurrency` control is defined for this workflow. Two manual dispatches run at the same time can race on rsync `--delete`, run database migrations concurrently, and interfere with PM2 reload, potentially corrupting the deployed directory. Add a `concurrency` group (e.g. with `cancel-in-progress: true`) keyed to the deploy target.