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