`status` is constrained only by the TypeScript union type (`'present' | 'absent'`) but is persisted as a plain `varchar(20)` with no database-level constraint. Any code path that bypasses TypeScript checking (raw SQL, migrations, future callers) can silently write an invalid status, and the `RoomInspectionStatus` type must be kept in sync manually. Consider using a database `enum` type or a CHECK constraint (e.g., `status IN ('present', 'absent')`) to enforce valid values at the DB layer.

  @Column({ type: 'varchar', length: 20 })
  status: RoomInspectionStatus;


Type mismatch: TypeORM maps `type: 'decimal'` to a string in MySQL drivers (DECIMAL/NUMERIC columns are returned as strings at runtime, not numbers). Declaring the property as `number` is therefore inaccurate — arithmetic like `monthlyRate * days` will do string concatenation, and JSON serialization will emit "1500.00" instead of 1500. This is already visible in the codebase: bills.service.spec.ts has to cast `'800' as unknown as number` to fit this type. Either declare the field as `string`, or use a transformer that converts to/from `number` (e.g. `{ to: (v) => v, from: (v) => parseFloat(v) }`).

@Column({ name: 'monthly_rate', type: 'decimal', precision: 10, scale: 2, default: 0 })
  monthlyRate: number;


Business values ('available', 'short') are hardcoded as bare strings with no constraint at the DB or type level, so any typo or invalid value (e.g. 'avaliable', 'long') silently persists. Consider using a dedicated enum type: `@Column({ type: 'enum', enum: ['available', 'occupied', ...], default: 'available' })` and a matching TS union type for `status`, and similarly for `rental_category` (e.g. 'short' | 'long'). This prevents invalid states and makes the allowed values self-documenting.

@Column({ type: 'varchar', length: 20, default: 'available' })
  status: string;


Type mismatch: DECIMAL columns are returned as strings by the MySQL/Postgres drivers, so `balance` will actually be a `string` at runtime even though it's typed `number`. This can cause silent bugs like string concatenation instead of arithmetic (e.g., `balance + amount`). Either declare the property as `string`, or add a transformer that converts the DB value to a number:

```ts
@Column({
  type: 'decimal',
  precision: 12,
  scale: 2,
  default: 0,
  transformer: {
    to: (v: number) => v,
    from: (v: string) => parseFloat(v),
  },
})
balance: number;
```

  @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })
  balance: number;


The `student_id` database column is declared twice: once explicitly via `@Column({ name: 'student_id' })` on `studentId`, and once implicitly via `@JoinColumn({ name: 'student_id' })` on the relation. TypeORM will register duplicate column metadata for the same DB column, which can lead to "Duplicate column" errors during schema sync/migrations and makes the two mappings drift apart. Prefer keeping only the relation and exposing the FK via `@RelationId`, or keep the explicit column and drop the `@JoinColumn` name duplication (e.g., let the relation own the column).

  @OneToOne(() => Student, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'student_id' })
  student: Student;


`eager: true` makes TypeORM automatically join and load the full Student record on EVERY StudentEnrollment query, including list/batch queries where the student data may not be needed. If Student has many columns or enrollment lists are large, this causes unnecessary joins, extra data transfer, and slower queries. Prefer explicit loading (`relations: ['student']` in find options or QueryBuilder `leftJoinAndSelect`) only where student data is actually required.

@ManyToOne(() => Student, { eager: true })


`student_id` is a foreign key that will typically be used to filter enrollments by student. No index is defined on it. While MySQL/InnoDB auto-creates an index for FK columns, PostgreSQL and several other databases do not, so lookups by student will result in full table scans as data grows. Add `index: true` to the column or an `@Index()` on the property.

@Column({ name: 'student_id', type: 'integer', index: true })

@Column({ name: 'student_id', type: 'integer' })


The `type: 'date'` columns are declared with TypeScript type `string`, but the runtime value returned by the driver depends on the database: MySQL returns date strings, whereas PostgreSQL (node-postgres) returns `Date` objects for DATE columns. If downstream code assumes `startDate`/`endDate` is always a string (e.g., calls `.slice()` or string comparison), it can fail at runtime. Align the declared type with the actual driver return value (e.g., `Date`) or normalize the value on read.

  @Column({ name: 'start_date', type: 'date', nullable: true })
  startDate: Date;

  @Column({ name: 'start_date', type: 'date', nullable: true })
  startDate: string;


The business state `'active'` is hardcoded as a magic string in the column default. If the set of valid statuses changes or the value needs to be referenced/compared elsewhere (e.g., filtering active enrollments), a magic string invites typos and drift. Consider defining a shared status enum/constant and referencing it here.

@Column({ length: 20, default: 'active' })


DECIMAL columns are returned as strings by the MySQL/PostgreSQL drivers; TypeORM does not auto-cast `decimal` to a JS number. Typing `amount` as `number` is therefore a type mismatch — at runtime it will be a string, so arithmetic (`+`), comparisons, and JSON serialization behave incorrectly (e.g., string concatenation). Type it as `string`, or add a transformer, e.g. `transformer: { to: (v) => v, from: (v) => parseFloat(v) }`.

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  amount: number;


`room_id` is declared `nullable: true`, but the property is typed as a non-nullable `number`; the same applies to `recorded_by` (`recordedBy: number`) and `description` (`string`). At runtime these fields can legitimately be `null`, which silently violates the TS contract and can cause null-pointer/type errors downstream. Note `billId` already uses the correct `number | null` — align these nullable columns with `number | null` / `string | null`.

  @Column({ name: 'room_id', nullable: true })
  roomId: number;


The database column `student_id` is mapped twice: once by the explicit `@Column({ name: 'student_id' })` and again by `@JoinColumn({ name: 'student_id' })` on the `student` relation. Depending on the TypeORM version this can raise a duplicate-column metadata error or cause the FK column to be emitted twice in generated SELECT/INSERT statements. Prefer a single source of truth — either keep the explicit `studentId` column and remove `@JoinColumn`, or keep the relation and drop the explicit `@Column` (accessing the FK through `student`).

  @Column({ name: 'student_id' })
  studentId: number;


Type mismatch: `inspectionDate` is declared as `string`, but this is a MySQL `date` column and the app does not configure `dateStrings` in its DataSource options (mysql2 defaults to returning JS `Date` objects). At runtime the hydrated property is a `Date`, so when entities are returned via `getByRoomsAndDate()` and JSON-serialized, values come out like `2026-08-08T16:00:00.000Z` (in Asia/Shanghai this shifts the calendar date to the previous day) instead of the `YYYY-MM-DD` string the rest of the code (e.g. `assertToday`, string date comparisons, frontend) expects. Align the property type with the runtime value (declare `Date` and convert explicitly), or add a column `transformer` that normalizes to a plain `YYYY-MM-DD` string.

  @Column({ name: 'inspection_date', type: 'date' })
  inspectionDate: string;


The `'manual' | 'automatic'` union for `source` is enforced only at compile time; the column is a plain `varchar(20)` with no database-level validation, so any invalid value would be silently persisted and would then break code that branches on `source` (e.g. `settleDate` looks up `source: 'manual'`). Consider declaring the column as `type: 'enum'` with the two values (or adding a CHECK constraint) so the DB enforces the invariant.

  @Column({ type: 'varchar', length: 20, default: 'manual' })
  source: RoomInspectionSource;


This table accumulates one row per sync run and is queried frequently by `SyncService.getLogs`/`getLastSync` with `where: { platform }` plus `order: { createdAt: 'DESC' }`. There is currently no index on `platform` or `created_at` (confirmed in migration `1784520727860-InitialSchema.ts`), so these lookups will degrade into a full table scan + filesort as the log table grows. Consider adding a composite index, e.g. `@Index('IDX_sync_logs_platform_created', ['platform', 'createdAt'])` on the entity (and a matching migration).

@Column({ type: 'varchar', length: 20 })
  platform: SyncPlatform;


This column is `nullable: true` in the DB but typed as non-nullable `string` in TypeScript. The same problem affects `lastLoginAt: Date` and `profile: {...}` below. TypeORM will return `null` for these columns, so code that directly accesses them (e.g. `user.lastLoginAt.toISOString()` or `user.profile.subjects`) can throw at runtime. The TS types should reflect nullability: `name: string | null`, `lastLoginAt: Date | null`, `profile: { ... } | null`.

  @Column({ length: 50, nullable: true })
  name: string | null;

  @Column({ length: 50, nullable: true })
  name: string;


`password_hash` is a sensitive field and is included in the default query result set. If a `User` entity is returned directly by a service/controller (or serialized via `class-transformer`/`JSON.stringify`), the password hash can leak to API consumers. Consider adding `select: false` to this column and explicitly selecting it only where needed (e.g. in the authentication flow), or excluding it during serialization.

  @Column({ name: 'password_hash', length: 255, select: false })
  passwordHash: string;

  @Column({ name: 'password_hash', length: 255 })
  passwordHash: string;


These commented-out column declarations are dead code. If they document a schema migration, consider recording the change in a migration file or changelog instead of leaving commented-out code in the entity, so the entity definition stays clean and the DB schema is version-controlled.

  // 移除: @Column({ type: 'varchar', length: 20, default: 'operator' })
  // role: string;


The `user_id` column is defined twice: once explicitly via `@Column` and once implicitly generated by `@JoinColumn({ name: 'user_id' })`. Since `synchronize` defaults to true in app.module.ts, having both definitions is risky (can cause "duplicate column" errors during schema sync) and leaves two sources of truth for the same column. Also, `unique: true` is redundant — a OneToOne owning side already creates a unique constraint on the join column. Prefer keeping only the relation (and access the FK value via `student.user.id`), or drop the `@JoinColumn` duplicate.

  @Column({ name: 'user_id', type: 'integer', nullable: true, unique: true })
  userId: number;

  @OneToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
  @JoinColumn({ name: 'user_id' })
  user: User;


Same issue as `user_id`: the `organization_id` column is declared twice — once via `@Column` and once via `@JoinColumn({ name: 'organization_id' })` on the `@ManyToOne`. With `synchronize: true`, TypeORM can generate conflicting column definitions. Additionally, `organization_id` is a frequently queried FK and has no index (TypeORM does not always auto-index FK columns); consider adding `@Index` if queries filter by organization.

  @Column({ name: 'organization_id', type: 'integer', nullable: true })
  organizationId: number;

  @ManyToOne(() => Organization, { nullable: true })
  @JoinColumn({ name: 'organization_id' })
  organization: Organization;


`id_number` stores the national ID (a highly sensitive piece of PII) in plaintext in the database. Anyone with DB access can read it, and a leak exposes irreversible personal data. Consider encrypting it at rest (e.g., application-level AES) or storing only a hashed value if full plaintext is not strictly needed, and restrict read access.

  @Column({ name: 'id_number', length: 30, nullable: true })
  idNumber: string;


Nullability mismatch: `student_id` is declared via `@Column` without `nullable`, so it is NOT NULL in the DB, but TypeORM relations default to `nullable: true`. As a result the TS type `student: Student` can actually be `undefined` at runtime whenever the relation is not explicitly joined, which can lead to null-pointer errors in consumers (e.g. services doing `mapping.student.xxx`). Align the definitions by declaring `@ManyToOne(() => Student, { nullable: false, onDelete: 'CASCADE' })` or typing the property as `Student | null` and null-checking at call sites.

  @ManyToOne(() => Student, { nullable: false, onDelete: 'CASCADE' })
  @JoinColumn({ name: 'student_id' })
  student: Student;

  @ManyToOne(() => Student, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'student_id' })
  student: Student;


Cardinality mismatch: `student_id` is declared `unique: true`, so a student can have at most one Ding mapping — this is strictly a 1:1 relationship, not a many-to-one. Using `@ManyToOne` here is misleading about the data model. Express the intent with `@OneToOne(() => Student, { onDelete: 'CASCADE' })` + `@JoinColumn({ name: 'student_id' })`, which also makes the `unique` constraint on the join column redundant/implicit.


The `student_id` column is declared twice: once via `@Column` and once implicitly through `@ManyToOne`/`@JoinColumn` with the same name. TypeORM 0.3.x reuses the existing column, but this leaves two sources of truth for the same DB column (type, nullability, uniqueness), and any drift between them (e.g. changing nullability on one side) can silently produce inconsistent DDL/runtime behavior. Keep a single source of truth — e.g. keep the `@Column` for `unique`, or drop it and enforce uniqueness via `@Index({ unique: true })` on the relation — and add a short comment explaining that this is an enforced 1:1 mapping.

  @Column({ name: 'student_id', type: 'integer', unique: true })
  studentId: number;


`SyncPlatform` is a shared domain type that is imported from another entity file (`sync-log.entity.ts`) rather than from a shared types module. This couples `SyncState` to `SyncLog` purely for a type: any rename/refactor of the type in the log entity silently breaks this entity, and the union may drift out of sync with the DB column. Recommend extracting `SyncPlatform` (and the other sync union types) into a dedicated shared types file and importing from there.

import type { SyncPlatform } from './sync-log.entity';


The `platform` column is sized `varchar(20)`, but the longest current `SyncPlatform` value (`'dingtalk_attendance'`) is already 19 characters. This leaves no headroom: adding a new platform with a name longer than 20 chars will cause a DB truncation/insert failure, and this length must be manually kept in sync with `migrations/1784520727860-InitialSchema.ts` and `init.sql`. Consider using a larger length (e.g., 32) or a DB-level CHECK constraint to enforce the allowed values.

@PrimaryColumn({ type: 'varchar', length: 20 })
  platform: SyncPlatform;


The `student_id` database column is mapped twice: once by the `@Column` property `studentId` and once by the `@JoinColumn({ name: 'student_id' })` of the `student` relation. TypeORM will register two column metadata entries for the same physical column, so an INSERT (e.g. `profileRepo.create({ ...dto, studentId })` in archive.service) can emit the column twice in the SQL (`INSERT INTO student_profiles (student_id, ..., student_id) ...`), causing MySQL error 1110 "Column 'student_id' specified twice" or ambiguous mapping at runtime. Keep only the relation (with `@JoinColumn`) and expose the FK id via `@RelationId`, or drop the relation and keep only the `@Column`.

  @RelationId((profile: StudentProfile) => profile.student)
  studentId: number;


`eager: true` on this `@ManyToOne` forces a LEFT/INNER JOIN on `students` for every `StudentProfile` query (findOne/find/save), even when only the FK is needed. Given `studentId` is already `unique`, this is effectively a 1:1 relationship — prefer `@OneToOne` without `eager` and load `student` explicitly where it is actually used (e.g. via `relations`/`leftJoinAndSelect`), which avoids the unconditional JOIN overhead and follows the pattern used elsewhere in this codebase (e.g. `Student.user` is not eager).

  @OneToOne(() => Student)
  @JoinColumn({ name: 'student_id' })
  student: Student;

  @ManyToOne(() => Student, { eager: true })
  @JoinColumn({ name: 'student_id' })
  student: Student;


`DECIMAL(12,2)` columns are returned as strings by the DB driver (MySQL/pg) and TypeORM performs no automatic conversion to number. Declaring `amount`/`balanceAfter` as `number` therefore misrepresents the runtime type: downstream code doing arithmetic (`tx.amount + x`) would get string concatenation, and `toFixed()`/numeric methods would throw. Type them as `string` or add an explicit transformer, e.g. `transformer: { to: (v) => v, from: (v) => Number(v) }`, so the entity type matches what is actually read back from the database.

  @Column({ type: 'decimal', precision: 12, scale: 2 })
  amount: number;

  @Column({ name: 'balance_after', type: 'decimal', precision: 12, scale: 2 })
  balanceAfter: number;


`operation_id` is limited to varchar(64), but `wallets.service.ts` `batchChangeBalance` writes a compound value `${operationId}:${studentId}` into this column. A user-supplied operationId near the 64-char limit plus the `:` and studentId suffix will exceed 64 characters, causing the INSERT to fail with a data-too-long error (the value is not re-validated before being persisted). Increase the column length (e.g., 128) or avoid embedding the studentId into the stored operationId.

  @Column({ name: 'operation_id', type: 'varchar', length: 64, nullable: true })
  operationId: string | null;


Category values ['room', 'personal', 'both'] are business constants hardcoded and duplicated verbatim in both CreateExpenseTypeDto and UpdateExpenseTypeDto, so they can silently drift out of sync. Extract a shared constant (e.g., `export const EXPENSE_TYPE_CATEGORIES = ['room', 'personal', 'both'] as const;` plus a derived union type) and use `@IsIn(EXPENSE_TYPE_CATEGORIES)` in both DTOs.

  @Matches(/\S/)
  name: string;

  @IsOptional()
  @IsIn(['room', 'personal', 'both'])
  category?: string;


API surface is inconsistent: CreateExpenseTypeDto has no way to set `enabled`, while UpdateExpenseTypeDto does, so a client cannot explicitly create a disabled expense type and must rely on the service's default. If defaulting to enabled on create is intended, document it (or add `enabled` to the create DTO for symmetry).


`onModuleInit` awaits `seedDefaults()` without any error handling. Any failure during seeding (e.g., the database is temporarily unavailable, a migration hasn't run yet, or a row violates a constraint) throws and aborts the entire application bootstrap — the HTTP server will never start. Consider wrapping the call in try/catch with error logging, or moving seeding to `onApplicationBootstrap` so a seeding failure doesn't crash startup (or at least document that a fail-fast boot is intentional).

  async onModuleInit() {
    try {
      await this.service.seedDefaults();
    } catch (err) {
      this.logger.error('Failed to seed default expense types', err);
    }
  }

  async onModuleInit() {
    await this.service.seedDefaults();
  }


The operation log is awaited without error handling after a successful DB write. If `logService.log` throws (e.g., DB insert failure), the whole request returns 500 even though the expense type was already created — the client may then retry and hit a duplicate-code conflict or create duplicates. Operation logging should be best-effort: wrap in try/catch (and log the failure) or don't block the response. The same pattern exists in `update` and `remove`.


The `extractRequestInfo` + `logService.log` block is duplicated across `create`, `update`, and `remove`. Extract a private helper (e.g., `logOperation(req, action, targetId, detail)`) to reduce duplication and make the logging behavior (including error handling) consistent.

    const { ipAddress, userAgent } = extractRequestInfo(req);
    const result = await this.service.create(dto);


`ParseIntPipe` already converts `id` to a number, so the unary `+` is a redundant no-op. Use `id` directly (same for the `remove` handler).

const result = await this.service.update(+id, dto);


Same as `update`: `ParseIntPipe` already yields a number, so `+id` is redundant — use `id` directly.


`exam:edit` is listed in DEPRECATED_PERMISSION_CODES (rbac-presets.ts) and is actively removed from every role and deleted from the permission table by rbac-seed.service.ts. As a result, for any non-superadmin user `req.user.permissions.includes('exam:edit')` can never be true, so the "manage all" branch is effectively dead code and users can never obtain global scope through RBAC (e.g., a user holding the active `exam:purge` permission would still be restricted to their own scope when purging). Use an active permission code or rework the scope model.

  private canManageAll(req: AuthenticatedRequest) {
    return req.user.isSuperAdmin || req.user.permissions.includes('exam:view');
  }

  private canManageAll(req: AuthenticatedRequest) {
    return req.user.isSuperAdmin || req.user.permissions.includes('exam:edit');
  }


Mutating endpoints (create, archive, restore, batch-archive, batch-restore, updateScore) are protected only by `@RequirePermission('exam:view')`, a view-level permission. Any user granted read access can create exams, archive/restore them, and overwrite scores — inconsistent with every other module in this codebase where create/edit/delete operations require dedicated `:create`/`:edit`/`:delete` codes. Since `exam:create`/`exam:edit` were deprecated, at minimum introduce a distinct edit permission for these mutations (or enforce an explicit scope/ownership check at the guard level) to preserve least privilege.

  @Post()
  @RequirePermission('exam:view')
  async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) {


`dto.score === null || dto.score === undefined` is evaluated three times in this handler. Extract it to a local constant to avoid duplication and keep the action/detail logic consistent.

    const isCleared = dto.score === null || dto.score === undefined;
    await logAudit(this.logService, req, {
      module: '考试管理', action: isCleared ? '清空成绩' : '录入成绩', targetId: scoreId, targetType: 'exam_score', detail: isCleared ? '成绩已清空' : `成绩：${dto.score}`,
    });

module: '考试管理', action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩', targetId: scoreId, targetType: 'exam_score', detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩：${dto.score}`,


Type-safety issue in the custom transform: when `value` doesn't match any recognized input (e.g., an invalid or empty string), the raw value is returned while being cast `as boolean`. It only 'works' because `@IsBoolean()` later rejects the invalid value, but the cast asserts a type contract the code doesn't actually guarantee and can mask future refactoring mistakes. Since `value` is already typed as `any` in `TransformFnParams`, the cast is unnecessary — remove it (or return an explicit boolean) so the transform's contract is honest.

    if (value === 'false' || value === '0') return false;
    return value;

    if (value === 'false' || value === '0') return false;
    return value as boolean;


`999.99` is a business rule (maximum allowed score) hardcoded inline. Per the hardcoding guideline, extract it into a named constant (e.g., `MAX_EXAM_SCORE`) so the rule is documented and reused consistently. Also note that `@Max` is inclusive (`score <= 999.99`); confirm that boundary is the intended one.

@IsOptional() @IsNumber() @Min(0) @Max(999.99) score?: number | null;


`examType` and `classId` are declared with near-identical validation in both `CreateExamDto` and `QueryExamDto` (only `@IsOptional`/`@Type` differ). Consider extracting the shared fields into a base DTO or shared partial so the validation rules can't drift between the create and query paths.

  @IsOptional() @IsString() keyword?: string;
  @IsOptional() @IsString() examType?: string;
  @IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number;


`remove()` soft-deletes a type by setting `enabled=false`, but this uniqueness check matches soft-deleted rows as well. As a result, re-creating a previously removed type with the same code always throws ConflictException, and `seedDefaults()` will never restore a disabled default type either. Scope the uniqueness check to enabled records only (and consider re-enabling soft-deleted defaults in `seedDefaults`).

    const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };
    const exists = await this.repo.findOne({ where: { code: normalized.code, enabled: true } });
    if (exists) throw new ConflictException('费用类型代码已存在');

    const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };
    const exists = await this.repo.findOne({ where: { code: normalized.code } });
    if (exists) throw new ConflictException('费用类型代码已存在');


`update` trims `name` but not `code` and performs no uniqueness validation, unlike `create`. A caller can set `code` to a value already used by another type (including a soft-deleted one), leading to duplicate codes and inconsistent data. Trim the code and check for conflicts against enabled records excluding the current id before saving.

  async update(id: number, dto: UpdateExpenseTypeDto): Promise<ExpenseType> {
    const t = await this.findOne(id);
    Object.assign(t, dto, dto.name === undefined ? {} : { name: dto.name.trim() });
    return this.repo.save(t);
  }


The find-then-save operations for each default type are independent, so they can be executed in parallel with `Promise.all` instead of awaiting sequentially. Also note the soft-delete interaction: a default type disabled via `remove()` will never be re-created here because the row still exists — if defaults should be restorable, re-enable existing disabled rows instead of skipping them.

  async seedDefaults(): Promise<void> {
    for (const t of DEFAULT_TYPES) {
      const exists = await this.repo.findOne({ where: { code: t.code } });
      if (!exists) {
        await this.repo.save(this.repo.create(t));
      }
    }
  }


No validation enforces that periodEnd >= periodStart. A client can submit a range where end precedes start (e.g., periodStart='2026-08-01', periodEnd='2026-01-01'), and this DTO will pass it through to the service. Add cross-field validation (e.g., a custom validator or service-level check) for CreateRoomExpenseDto, BatchRoomExpenseDto, and CreateStudentUtilityBillDto. The same applies to QueryRoomExpenseDto where a reversed range can silently return an empty result.

  @IsDateString()
  periodEnd: string;

  @IsOptional()
  @IsString()
  description?: string;
}


These period fields are validated only by a format regex (`^\d{4}-\d{2}-\d{2}$`), which accepts impossible dates such as '2026-99-99' or '2026-02-30'. This is inconsistent with the other DTOs in this file (CreateRoomExpenseDto / CreatePersonalExpenseDto) that also use `@IsISO8601({ strict: true })` to verify the value is a real calendar date. Add the strict ISO8601 check here as well.

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

  @IsString()
  @Matches(/^\d{4}-\d{2}-\d{2}$/)
  periodStart: string;


Redundant date validators: `@IsDateString()` is fully subsumed by `@IsISO8601({ strict: true })` (strict ISO8601 is a stricter subset of IsDateString), and `@Matches` already pins the exact YYYY-MM-DD format. Having all three decorators is confusing and gives no extra protection. Keep `@Matches` (to enforce date-only format) plus `@IsISO8601({ strict: true })`, and drop `@IsDateString()`. This pattern appears in CreateRoomExpenseDto, CreatePersonalExpenseDto, and BatchRoomExpenseDto.

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

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


Inconsistent date validation: create/update DTOs require strict date-only strings (`YYYY-MM-DD` via `@Matches` + `@IsISO8601({ strict: true })`), but the query DTOs only use `@IsDateString()`, which also accepts full ISO timestamps (e.g., '2026-01-01T12:00:00.000Z'). If the query parameters are compared against date-only columns, values like these can cause unexpected filtering behavior. Align the query DTO validators with the create DTOs for a consistent contract.

  @IsOptional()
  @IsDateString()
  periodEnd?: string;


Nested ternary expression (prohibited by review rules) combined with a `-1` magic sentinel to fake "no match". This is fragile: if any class ever has id <= 0, or the access-check logic changes, it silently returns wrong results instead of denying access. Also, silently returning [] for a class the teacher cannot access is inconsistent with `assertClassAccess` (which throws 403) used everywhere else. Refactor to if/else and return early:

      if (query.classId) {
        if (!accessibleClassIds.includes(query.classId)) return [];
        where.classId = query.classId;
      } else {
        where.classId = In(accessibleClassIds);
      }

      where.classId = query.classId
        ? accessibleClassIds.includes(query.classId)
          ? query.classId
          : -1
        : In(accessibleClassIds);


Batch purge deletes rows one at a time in a loop and is not wrapped in a transaction. A failure partway through leaves a partially deleted batch (inconsistent state), and each delete is a separate round-trip (N queries). Collect the archived ids and delete them in a single `IN` query; wrap the whole read-check-delete flow in a transaction for atomicity.

      const archivedIds = exams.filter((exam) => exam.status === 'archived').map((exam) => exam.id);
      if (archivedIds.length > 0) {
        await this.examRepo.delete({ id: In(archivedIds) });
      }
      deleted.push(...archivedIds);

      await this.examRepo.delete(exam.id);
      deleted.push(exam.id);


TOCTOU race: the status check (`findBatchExams`) and the status update (`updateBatchStatus`) are two separate, non-transactional steps. If an exam's status changes (or it is purged) between the read and the write, the returned `archived`/`skipped` counts and message become inaccurate. Consider wrapping the read+update in a transaction or re-checking the affected count to keep batch results consistent. The same pattern exists in `batchRestore`.

    const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id);
    const archived = await this.updateBatchStatus(targetIds, 'archived');


`findAll` eagerly fetches all matching exams and aggregates their score rows, but only the first `limit` results are actually used here. For agents with many exams this performs full aggregation (a second query over all score rows) for exams that are immediately sliced away. Consider adding a `take`/limit parameter to `findAll` so it stops after `limit` results instead of post-processing everything.

    const limit = Math.max(1, Math.min(query?.limit ?? 20, 50));
    return exams.slice(0, limit).map((exam) => ({


No validation is applied to the incoming `score` before persistence. The column is `decimal(5,2)` (max 999.99), so out-of-range values will fail at the DB layer, and non-finite values (if they ever reach here) would silently corrupt the class average/rank computed by `recalculate`. Validate that the score is a finite number within a sensible range (e.g., 0–150/999.99) before saving.

row.score = score === undefined ? null : score;


Non-atomic retry claim can cause duplicate execution. When an existing operation is 'failed' (or any non-completed/non-running state), the transition to 'running' is a read-modify-write (findOne → save) with no locking. Two concurrent retry requests can both read status='failed', both save 'running', and both execute work() — for financial operations this can produce duplicate payments/refunds. Use an atomic conditional claim (UPDATE ... WHERE status='failed' and check affected rows) or a pessimistic write lock within a transaction so only one request claims the operation; also re-fetch the row after claiming so the later status updates use fresh data.

    } else {
      const claim = await this.repo.update(
        { operationId, status: 'failed' },
        { status: 'running', errorMessage: null, resultJson: null },
      );
      if (!claim.affected) {
        const fresh = await this.repo.findOne({ where: { operationId } });
        if (fresh?.status === 'completed' && fresh.resultJson) return JSON.parse(fresh.resultJson) as T;
        throw new ConflictException('该操作正在处理中，请勿重复提交');
      }
      operation = (await this.repo.findOne({ where: { operationId } }))!;
    }

    } else {
      operation.status = 'running';
      operation.errorMessage = null;
      operation.resultJson = null;
      await this.repo.save(operation);
    }


The catch block assumes every save failure is a duplicate-key error. If the insert fails for another reason (e.g., a transient DB/connection error), the code re-fetches and, finding no completed concurrent row, throws ConflictException — masking a real infrastructure failure as "operation in progress" and losing the original error detail. Only treat the failure as a concurrency conflict when it is actually a unique-constraint violation (e.g., Postgres code 23505 / MySQL ER_DUP_ENTRY); otherwise rethrow the original error.

      } catch (error) {
        const code = (error as { driverError?: { code?: string }; code?: string })?.driverError?.code ?? (error as { code?: string })?.code;
        if (code !== '23505' && code !== 'ER_DUP_ENTRY') throw error;
        const concurrent = await this.repo.findOne({ where: { operationId } });
        if (concurrent?.status === 'completed' && concurrent.resultJson) {
          return JSON.parse(concurrent.resultJson) as T;
        }
        throw new ConflictException('该操作正在处理中，请勿重复提交', { cause: error });
      }

      } catch (error) {
        const concurrent = await this.repo.findOne({ where: { operationId } });
        if (concurrent?.status === 'completed' && concurrent.resultJson) {
          return JSON.parse(concurrent.resultJson) as T;
        }
        throw new ConflictException('该操作正在处理中，请勿重复提交', { cause: error });
      }


A 'running' record has no timeout/lease, so if the worker crashes (or times out) mid-execution, the row stays 'running' forever and every subsequent retry fails with ConflictException — the operation is permanently blocked even though nothing is actually executing. Consider reclaiming stale 'running' records (e.g., update updated_at as a heartbeat and treat records older than a threshold as abandoned), or provide an explicit admin reset path.

if (existing.status === 'running') throw new ConflictException('该操作正在处理中，请勿重复提交');


JSON.parse of the cached resultJson can throw (corrupt/truncated data), which would surface as an unhandled 500. Consider wrapping the parse in try/catch and falling back to re-execution or a clear error, so a bad cached payload doesn't permanently break the operation path.

if (existing.status === 'completed' && existing.resultJson) return JSON.parse(existing.resultJson) as T;


Missing null check for the uploaded file: if the request contains no `file` part (e.g., wrong field name or file not attached), `file` is `undefined` and `file.buffer` throws a TypeError, producing an unhandled 500. Also, `workbook.xlsx.load` throws a generic error for invalid/non-xlsx content with no user-friendly message. Add a guard and wrap parsing in try/catch, throwing BadRequestException (imported from '@nestjs/common').

  @UseInterceptors(FileInterceptor('file'))
  async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
    if (!file) throw new BadRequestException('请上传 Excel 文件');
    const workbook = new ExcelJS.Workbook();
    try {
      await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));
    } catch {
      throw new BadRequestException('文件格式无效，请上传 .xlsx 模板文件');
    }

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


Same issue as the utility import endpoint: `file` can be `undefined` when the request has no `file` part, and `file.buffer` will throw an unhandled TypeError (500). Invalid Excel content also surfaces as a generic error. Add a null guard and a try/catch that throws a user-friendly BadRequestException.

  @UseInterceptors(FileInterceptor('file'))
  async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
    if (!file) throw new BadRequestException('请上传 Excel 文件');
    const workbook = new ExcelJS.Workbook();
    try {
      await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));
    } catch {
      throw new BadRequestException('文件格式无效，请上传 .xlsx 模板文件');
    }

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


FileInterceptor is used without `limits` or `fileFilter` on both import endpoints. The entire uploaded file is buffered in memory (`file.buffer`) and fully parsed by ExcelJS; an arbitrarily large file can exhaust server memory (DoS). Add size limits (e.g. `limits: { fileSize: 5 * 1024 * 1024 }`) and restrict to `.xlsx` via `fileFilter` for both `/utility/import` and `/personal/import`.

  @Post('personal/import')
  @RequirePermission('expense:create')
  @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (_req, f, cb) => cb(null, /xlsx$/.test(f.originalname)) }))

  @Post('personal/import')
  @RequirePermission('expense:create')
  @UseInterceptors(FileInterceptor('file'))


Dead branch: `v` was already checked for null at the top of `readCell` (`if (v == null) return '';`), so at this final line the ternary always evaluates to `''`. Simplify to avoid misleading code.

  if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
  return '';

  if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
  return v == null ? v : '';


Unlike the batch-restore endpoints (which use BatchIdsDto with `whitelist`/`forbidNonWhitelisted`), these batch-delete/purge endpoints accept a raw, unvalidated body and silently fall back to `[]` when `ids` is missing or invalid (`body.ids || []`) — an invalid request silently deletes nothing. For consistency and correctness, use BatchIdsDto with ValidationPipe here too (applies to batchDelete/batchPurge for both room and personal expenses).

  @Post('room/batch-delete')
  @RequirePermission('expense:delete')
  @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
  async batchDeleteRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {

  @Post('room/batch-delete')
  @RequirePermission('expense:delete')
  async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {


The default status 'pending' is a hardcoded business string. `imports.types.ts` already centralizes step/status domain values, so a shared constant (e.g. `DEFAULT_STEP_STATUS = 'pending'`) should be exported and referenced here to keep the single source of truth and avoid drift if the default status ever changes.

@Column({ name: 'status', type: 'varchar', length: 20, default: DEFAULT_STEP_STATUS })

@Column({ type: 'varchar', length: 20, default: 'pending' })


All sibling columns explicitly declare snake_case `name` (e.g. `run_id`, `step_key`, `sheets_json`), but `status` relies on the default naming strategy. Today this happens to resolve to `status`, so it works, but it is inconsistent with the rest of the entity and would silently break if a custom naming strategy (or column rename) is introduced. Add an explicit `name: 'status'` for consistency.

  @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' })
  status: ImportStepStatus;


`status` is returned in the result mapping (`status: String(row.status)`) but `e.status` is never added to `roomExpenseSelects`. `getRawMany` only returns the selected columns, so `row.status` is always `undefined` and every agent-search result will report `status: "undefined"` instead of `active`. Add `['e.status', 'status']` to the selects (and likewise for the personal-expense query below).

    const roomExpenseSelects = [
      ['e.expenseType', 'expenseType'],
      ['e.amount', 'amount'],
      ['e.periodStart', 'periodStart'],
      ['e.periodEnd', 'periodEnd'],
      ['room.roomNumber', 'roomNumber'],
      ['e.status', 'status'],
    ] as const;

    const roomExpenseSelects = [
      ['e.expenseType', 'expenseType'],
      ['e.amount', 'amount'],
      ['e.periodStart', 'periodStart'],
      ['e.periodEnd', 'periodEnd'],
      ['room.roomNumber', 'roomNumber'],
    ] as const;


Same issue as the room query: `personalExpenseSelects` does not include `e.status`, yet the mapping below calls `String(row.status)`. Since `getRawMany` only returns selected columns, `row.status` is `undefined` and every result reports `status: "undefined"`. Add `['e.status', 'status']` to the selects.

    const personalExpenseSelects = [
      ['e.expenseType', 'expenseType'],
      ['e.amount', 'amount'],
      ['e.expenseDate', 'expenseDate'],
      ['student.name', 'studentName'],
      ['student.studentNo', 'studentNo'],
      ['e.status', 'status'],
    ] as const;

    const personalExpenseSelects = [
      ['e.expenseType', 'expenseType'],
      ['e.amount', 'amount'],
      ['e.expenseDate', 'expenseDate'],
      ['student.name', 'studentName'],
      ['student.studentNo', 'studentNo'],
    ] as const;


`String(row.periodStart)` / `String(row.periodEnd)` will not produce the `YYYY-MM-DD` format declared by the return type when the DB driver returns `Date` objects (the MySQL driver returns `Date` by default and no `dateStrings` option was found in this project). `String(date)` yields a locale-dependent full timestamp (e.g. "Sat Aug 09 2026 08:00:00 GMT+0800 ..."), which can break downstream agent consumers that expect date strings. Format explicitly with dayjs (used elsewhere in this file) and guard nulls, e.g. `row.periodStart == null ? '' : dayjs(row.periodStart).format('YYYY-MM-DD')`. Same applies to `expenseDate` in the personal mapping.

        amount: Number(row.amount),
        periodStart: row.periodStart == null ? '' : dayjs(row.periodStart).format('YYYY-MM-DD'),
        periodEnd: row.periodEnd == null ? '' : dayjs(row.periodEnd).format('YYYY-MM-DD'),

        amount: Number(row.amount),
        periodStart: String(row.periodStart),
        periodEnd: String(row.periodEnd),


This loop performs multiple `roomExpRepo.delete` calls without a transaction. If a later delete fails (or a bill is attached between the `bill_items` count above and the delete), earlier records are already permanently removed, leaving a partially applied operation with no rollback. Wrap the purge in `dataSource.transaction` and delete via the transaction manager to make the batch delete atomic.

    const deleted: number[] = [];
    const skipped: string[] = [];
    for (const e of existing) {
      if (e.status !== 'archived') {
        skipped.push(`记录${e.id}（未归档）`);
        continue;
      }
      await this.roomExpRepo.delete(e.id);
      deleted.push(e.id);
    }


`updateRoomExpense` does not check `e.status`, so an already-archived expense can still be modified. This is inconsistent with `deleteRoomExpense`/`purgeRoomExpense`, which explicitly forbid archived records, and can silently resurrect or mutate data the user intended to be frozen. Also note the billed check is executed before the existence check, so the error message for a non-existent id is misleadingly tied to billing; check existence first.

  async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
    const e = await this.roomExpRepo.findOne({ where: { id } });
    const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });
    if (billed) throw new BadRequestException('已计入账单的宿舍费用不能修改，请先取消账单');
    if (!e) throw new NotFoundException('费用记录不存在');


The raw repository name string `'bill_items'` is used in six methods here; it silently breaks if the entity/table is renamed and provides no type safety. Additionally `dataSource?.` optional chaining is unnecessary because `dataSource` is a required constructor dependency (and other methods call it without `?.`). Prefer a typed repository (e.g. `@InjectRepository(BillItem)`) or at least keep the lookup consistent.

    const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });
    if (billed) throw new BadRequestException('已计入账单的宿舍费用不能归档，请先取消账单');


The DB-level default `'preparing'` for `status` (and similarly `'manual'` for `source`) is dead configuration: the only creation path (`ImportRunService.createRun` in imports.run.service.ts) always sets `status: 'ready'` and `source` explicitly, and `'preparing'` never appears anywhere in the app. A dead default can silently mask missing-field bugs in future code paths (a run created without `status` would land in an unhandled state) and makes the schema misleading. Consider removing the defaults, or aligning them with the actual creation flow.

  @Column({ type: 'varchar', length: 20, default: 'preparing' })
  status: ImportRunStatus;


The length 500 is a magic number duplicated at the write site (`imports.commit.service.ts: run.error = safeError(error).slice(0, 500)`). If either side changes without the other, errors will either be silently truncated or the column will overflow (raising a 'Data too long' error under strict SQL mode). Suggest extracting a shared constant (e.g., `MAX_RUN_ERROR_LENGTH`) or widening the column to `TEXT` to eliminate the drift risk.

  @Column({ type: 'varchar', length: 500, nullable: true })
  error: string | null;


No unique constraint guards against duplicate rows for the same source row. The preview flow (imports.preview.service.ts) deletes rows by stepId and then re-inserts them, so a concurrent/double preview invocation can insert the same source row twice (the delete+insert is not a DB-level atomic operation, and nothing here prevents it). Since one step can span multiple sheets, row numbers are only unique per sheet, so the unique key should cover (runId, stepId, sheetName, rowNumber). Consider adding e.g. @Index('uk_import_rows_run_step_sheet_row', ['runId', 'stepId', 'sheetName', 'rowNumber'], { unique: true }).

@Index('idx_import_rows_step', ['stepId'])
@Index('idx_import_rows_run_status', ['runId', 'status'])
@Index('uk_import_rows_run_step_sheet_row', ['runId', 'stepId', 'sheetName', 'rowNumber'], { unique: true })


Rows are mutated after creation (status transitions pending→valid→committed, errors_json set/cleared, target_id filled in during commit), yet the entity only has created_at and no update tracking. The sibling ImportRun entity defines @UpdateDateColumn({ name: 'updated_at' }), so this is an inconsistency that makes it impossible to audit when a row's status last changed. Consider adding an updated_at column.

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;


Utility fees are only checked with `fee > 0`, but never go through `assertPositiveAmount` (finite / max 2 decimals / > 0) which the personal import path uses. A value with more than 2 decimals (e.g. `100.123`) or a sub-cent positive value (`0.001`) will be written into the `decimal(10,2)` `amount` column and silently rounded/truncated by the DB (or fail the whole row in strict mode), so the stored money won't match the source. Also `totalFee` (应缴金额) is never validated or used. Suggest reusing `assertPositiveAmount` for `electricityFee`/`waterFee` before importing, and validating `totalFee` if it's expected to equal the sum.

if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {


`userId!` masks a possibly-`undefined` value: the method signature is `userId?: number`, but `importUtilityExpense` types `recordedBy: number`. If the caller doesn't provide a user, `recordedBy` receives `undefined` at runtime and NULL is silently persisted in `recorded_by` (column is nullable). Either type the parameter as `number | undefined` (and persist it as-is) or explicitly reject/require `userId` when importing.

            byType,
            userId!,


Unlike the sibling methods `batchRestorePersonalExpenses`/`batchPurgePersonalExpenses`, this method never validates IDs with `Number.isInteger(id) && id > 0`. Additionally, unlike the single `deletePersonalExpense` (which throws on already-archived records), the batch path will silently re-archive already-archived records and count them in `result.affected`, inflating the reported "archived" count. Add the same integer-ID validation and decide whether already-archived records should be skipped instead of counted.

  async batchDeletePersonalExpenses(ids: number[]) {
    const uniqueIds = [...new Set(ids || [])];
    if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');


`dto.studentId` existence is validated before `Object.assign(e, dto)`, but `dto.roomId` is not — and the same gap exists in `createPersonalExpense` (`this.personalExpRepo.create({ ...dto, recordedBy: userId })`). Since `personal_expenses.room_id` has no FK/relation defined in the entity, an invalid `roomId` is silently persisted as an orphan reference that will surface as bad data in reports. Validate room existence whenever `roomId` is provided.

    if (dto.studentId !== undefined && dto.studentId !== e.studentId) {
      const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
      if (!student) throw new NotFoundException('学生不存在');
    }


Each row in this loop performs several sequential awaited DB round trips (room find/create, existing expense lookup, per-type save), making an Excel import O(rows × ~5) sequential queries. Rows are independent, so consider processing with bounded concurrency or batching (keeping per-row error collection) to cut latency for large files — taking care to handle races on the unique `room_number`/`import_key` constraints (e.g., retry on duplicate-key). The same pattern applies to `batchImportPersonalExpenses`.

    for (let i = 0; i < rows.length; i++) {
      const row = rows[i];
      const rowNum = i + 2;


When any organization name appears in the import, this loads the ENTIRE organizations table instead of just the referenced rows. For a large organization table this wastes bandwidth/memory and adds latency to every student import. Filter by the collected names with `In([...organizationNames])`.

  const organizations =
    organizationNames.size > 0
      ? await dataSource.getRepository(Organization).find({
          where: { name: In([...organizationNames]) },
        })
      : [];

  const organizations =
    organizationNames.size > 0 ? await dataSource.getRepository(Organization).find() : [];


For large import files, `studentNos`/`phones`/`roomNumbers` can contain thousands of entries, and the `In([...])` clauses built here (and for Room below) will exceed the query parameter limit of the underlying DB (e.g. SQLite ~999/32766 variables, PostgreSQL 65535) or cause a severe query-plan slowdown. Consider chunking the IN lists (e.g. batches of 500-1000) or running batched queries and merging results.

  if (studentNos.size > 0) {
    students.push(
      ...(await dataSource.getRepository(Student).find({
        where: { studentNo: In([...studentNos]) },
      })),
    );
  }


The two student lookups (by studentNo and by phone) are independent and executed sequentially, doubling the round-trip latency. Run them in parallel with `Promise.all` (and consider merging the results into one combined query with an OR condition if the entity supports it).

  const students: Student[] = [];
  if (studentNos.size > 0) {
    students.push(
      ...(await dataSource.getRepository(Student).find({
        where: { studentNo: In([...studentNos]) },
      })),
    );
  }
  if (phones.size > 0) {
    students.push(
      ...(await dataSource.getRepository(Student).find({
        where: { phone: In([...phones]) },
      })),
    );
  }


CSV formula injection (security): `csvCell` only escapes double quotes (`""`), but the raw data written here comes directly from user-uploaded files (untrusted). If any imported cell value starts with `=`, `+`, `-`, or `@` (e.g. `=HYPERLINK(...)` / `=cmd|...`), opening the exported report in Excel/Sheets will interpret it as a formula and execute it. The same applies to `csvCell(errors.join('；'))` since DB error messages may embed user data. Fix: neutralize leading formula characters in `csvCell` (prefix with `'` or tab) or sanitize before writing.


Concurrency hazard: the run/step transition to 'committing' is a plain `save` outside any transaction, lock, or optimistic-version column (no @Version exists in the module). Two concurrent `commitStep` calls for the same run can both pass the earlier status checks (both see `ready`) and both run the write transaction, producing duplicate created/updated records. Use an atomic conditional update (e.g. `UPDATE ... WHERE status='ready'` and check affected rows) or a pessimistic/optimistic lock around the state transition.

    run.status = 'committing';
    step.status = 'committing';
    await this.runs.save(run);
    await this.steps.save(step);


Recovery bug: if the write transaction throws, `run` is set to `failed`, but `step.status` was already saved as `committing` before the transaction and is never reset. Any subsequent retry hits `if (step.status !== 'ready')` and throws '尚未预览', so the run/step is permanently stuck with no recovery path (the user can neither retry nor re-preview). Reset `step.status` back to `ready` (or `failed`) in the catch block so the run can be recovered.

      run.status = 'failed';
      run.error = safeError(error).slice(0, 500);
      await this.runs.save(run);
      throw new ConflictException(`提交失败：${safeError(error)}`);


Silent default to `create` for undecided rows: valid rows not present in `decisions` and lacking a preview `row.action` will be automatically created here. This contradicts the earlier validation (which only allows undecided rows to be skipped) and can commit rows the user never confirmed. Default undecided rows to `skip` instead, or throw a clear error when `row.action` is missing for a non-decided row.

const action = decisionMap.get(row.id) ?? row.action ?? 'create';


Timezone inconsistency in the Date branch: `dayjs(value)` formats in the server's local timezone, while the string branch below computes a strictly UTC calendar date. The shared `common/dayjs` only extends the `utc` plugin but never actually parses/formats in UTC, so this depends on deployment timezone. ExcelJS returns date cells as UTC-midnight Dates; on any server west of UTC (offset < 0) the formatted date will be shifted back one day, producing off-by-one dates for valid Excel rows. Use `dayjs.utc(value).format('YYYY-MM-DD')` to stay consistent with the UTC logic of the string branch.

  if (value instanceof Date && !Number.isNaN(value.getTime())) {
    return dayjs.utc(value).format('YYYY-MM-DD');
  }

  if (value instanceof Date && !Number.isNaN(value.getTime())) {
    return dayjs(value).format('YYYY-MM-DD');
  }


CSV formula-injection risk: `csvCell` is used in `imports.commit.service.ts` to export raw user-provided row data (`JSON.stringify(raw)`, sheet names, error text) into an error-report CSV. Wrapping in double quotes does NOT stop Excel from evaluating cells whose content starts with `=`, `+`, `-`, `@`, `\t` or `\r`, which can execute external formulas/hyperlink payloads when the file is opened. Neutralize such leading characters (e.g., prefix with a single quote) before quoting.

export function csvCell(value: string): string {
  const escaped = value.replace(/^[=+\-@\t\r]/, "'").replace(/"/g, '""');
  return `"${escaped}"`;
}

export function csvCell(value: string): string {
  return `"${value.replace(/"/g, '""')}"`;
}


The date regex is only anchored at the start, not the end, so inputs with arbitrary trailing garbage (e.g. `2024-01-15abc`) silently parse as a valid date instead of being rejected. If datetime values like `2024-01-15 10:30` are intended, allow only trailing whitespace/time separators; otherwise require `$`.


`cellValue` drops ExcelJS rich-text cells: a formatted cell is exposed as `{ richText: [{ text, font }] }`, which falls through both `candidate.text` and `candidate.result` checks and returns `null`, silently losing the visible text. A formula cell whose `result` is a boolean is also dropped. Handle `richText` (concatenate `text` fields) and boolean `result` values so those cells are imported instead of becoming empty.

  if (typeof value === 'object') {
    const candidate = value as {
      text?: unknown;
      result?: unknown;
      richText?: Array<{ text?: unknown }>;
    };
    if (Array.isArray(candidate.richText)) {
      const rich = candidate.richText.map((t) => (typeof t.text === 'string' ? t.text : '')).join('');
      if (rich) return rich;
    }

  if (typeof value === 'object') {
    const candidate = value as { text?: unknown; result?: unknown };


`STEP_PERMISSIONS[stepKey]` can be `undefined` at runtime when `stepKey` is not one of the four union keys (e.g., a value coming from a request body/param that was only cast to `ImportStepKey` without validation). Calling `required.some(...)` on `undefined` throws a raw `TypeError`, surfacing as a 500 instead of a clean 4xx. Although the controller currently validates via `parseStepKey`, this helper is reusable and should not assume a valid key. Add a null guard before dereferencing `required`.

  const required = STEP_PERMISSIONS[stepKey];
  if (!required) {
    throw new ForbiddenException(`未知导入阶段：${stepKey}`);
  }
  if (!required.some((code) => principal.permissions.includes(code))) {

  const required = STEP_PERMISSIONS[stepKey];
  if (!required.some((code) => principal.permissions.includes(code))) {


TypeORM silently omits `undefined` values when building the `where` clause. If `userId` (or `runId`) is ever `undefined` at runtime, the ownership filter is dropped and the query may return another user's run instead of throwing NotFoundException — an authorization bypass in an ownership-gate helper. Since this function is the access-control boundary for import runs, defensively validate the inputs (e.g., `Number.isInteger(userId)` and a non-empty `runId`) before querying. `findStep` below shares the same pattern.

  if (!Number.isInteger(userId) || !runId) {
    throw new NotFoundException('导入任务不存在');
  }
  const run = await runs.findOne({ where: { id: runId, userId } });


The policy classification is based on matching hardcoded Chinese UI message substrings (e.g. '请勿重复导入', '未找到宿舍') that are generated elsewhere in imports.rows.ts (lines 135-202). This couples business logic to user-facing display text: any wording change or typo in those messages will silently disable duplicate-skip / skip-unmatched behavior with no compile-time or type-level protection, and a refactor of the messages breaks policy silently. Suggest introducing structured error categories (e.g. a machine-readable error code/type added to each error, or shared constant markers) instead of string matching on localized messages.

function isDuplicateError(error: string): boolean {
  return (
    error.includes('请勿重复导入') ||
    error.includes('请勿重复换宿') ||
    error.includes('本次文件中已有')
  );
}


When duplicatePolicy === 'skip', status is unconditionally forced to 'valid' even if non-duplicate errors remain in keptErrors (e.g. a row that is both a duplicate and also fails student/room lookup). The returned row then carries status 'valid' while errors still contain real error text — an inconsistent state that the preview summary in imports.preview.service.ts counts as valid. In addition, because the skipUnmatched branch below only triggers on `status === 'error'`, the retained reference errors never get the '未匹配学生/宿舍，按策略跳过' explanation. Consider only setting status to 'valid' when keptErrors is empty, or keying the skipUnmatched branch off the error content rather than the status flag.

  if (duplicatePolicy === 'skip' && errors.some(isDuplicateError)) {
    action = 'skip';
    status = 'valid';


The existing preview rows are deleted BEFORE the new rows are validated and saved, and the delete + insert + step update are not wrapped in a transaction. If `buildLookups`/`validateRow` throws mid-loop (DB errors, unexpected data shapes) or `rows.save` fails, the step's previously stored preview rows are already gone while `step.summaryJson`/`step.status` remain stale, leaving inconsistent state. Two concurrent previews for the same step can also interleave delete/save and corrupt the row set. Wrap the whole delete + rebuild + save in `dataSource.transaction(...)`.

await this.rows.delete({ stepId: step.id });


`usedSheets` keeps duplicates if `body.sheets` contains the same sheet name more than once (or if the run's `sheetsJson` itself contains duplicate sheet entries). The same sheet would then be processed multiple times: rows are inserted twice with identical rowNumbers, and `summary.total/valid/create/...` are double-counted. The duplicates are also persisted into `step.sheetsJson`, which can propagate to the later commit phase. Deduplicate before processing.

const usedSheets = [...new Set(sheetNames.filter((name) => sheetsData.some((s) => s.name === name)))];

const usedSheets = sheetNames.filter((name) => sheetsData.some((s) => s.name === name));


Inside the per-row loop, `sheet.headers.indexOf(header)` rescans the whole header array for every mapped field of every row (O(rows × fields × headers)). For large sheets this is needless repeated work — build a header→index lookup once per sheet and reuse it for all rows. This also avoids the `indexOf` returning -1 fallback being re-evaluated each iteration.

        const headerIndex = new Map(sheet.headers.map((h, index) => [h, index]));
        const fields: Record<string, CellValue> = {};
        for (const [field, header] of Object.entries(sheetMapping)) {
          fields[field] = rawValues[headerIndex.get(header) ?? -1] ?? null;
        }

        const fields: Record<string, CellValue> = {};
        for (const [field, header] of Object.entries(sheetMapping)) {
          fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;
        }


This is a nested ternary chain (`? 6 : ... ? 6 : 0`), which is disallowed by the review rules and makes the scoring logic hard to read. Additionally, the alias lists here are hardcoded duplicates of IMPORT_FIELD_ALIASES (checkins.checkInDate / transfers.oldRoom|newRoom|transferDate); if aliases are later changed in imports.types, this signal logic silently drifts. Consider extracting the signal computation into a helper using if/else and deriving the alias lists from IMPORT_FIELD_ALIASES.

    let signal = 0;
    if (stepKey === 'checkins') {
      if (hasAny(['入住日期', '入住时间'])) {
        signal = 6;
      } else if (hasAny(['宿舍号', '房间号', '房号']) && hasAny(['入住日期', '入住时间', '日期'])) {
        signal = 6;
      }
    } else if (
      stepKey === 'transfers' &&
      hasAny(['原宿舍', '原房间', '原宿舍号', '新宿舍', '新房间', '新宿舍号', '换宿日期', '变更日期'])
    ) {
      signal = 6;
    }

    const signal =
      stepKey === 'checkins' &&
      (hasAny(['入住日期', '入住时间']) ||
        (hasAny(['宿舍号', '房间号', '房号']) && hasAny(['入住日期', '入住时间', '日期'])))
        ? 6
        : stepKey === 'transfers' &&
            hasAny(['原宿舍', '原房间', '原宿舍号', '新宿舍', '新房间', '新宿舍号', '换宿日期', '变更日期'])
          ? 6
          : 0;


`matchedFields` is being assigned the weighted total score (matched count + identity*3 + required*2 + signal), not the number of matched fields as the name/type implies. The comparison `score > best.matchedFields` only works because both sides happen to store the same weighted value; any future code reading `matchedFields` as a raw field count will be misled. Store the raw matched count in `matchedFields` and track the weighted score separately (e.g., a `score` field), or rename the property.

    if (!best || score > best.matchedFields) {
      best = { stepKey, mapping, matchedFields: score };
    }


The three sub-services (ImportRunService, ImportPreviewService, ImportCommitService) are manually instantiated with `new` here, bypassing NestJS DI. They are decorated with `@Injectable()` and `@InjectRepository` (which are inert when constructed manually), are not registered as providers in `imports.module.ts`, and any new constructor dependency must be hand-threaded through `ImportsService`. This also prevents lifecycle hooks and makes the sub-services impossible to mock/override in NestJS testing modules. Consider registering them as providers in the module and injecting them normally, e.g. adding `private readonly runService: ImportRunService` (plus the preview/commit services) to this constructor.

  private get runsSvc(): ImportRunService {
    return this.runService;
  }

  private get runsSvc(): ImportRunService {
    if (!this.runService) {
      this.runService = new ImportRunService(this.runs, this.steps);
    }
    return this.runService;
  }


`IMPORT_STEP_ORDER` duplicates `IMPORT_STEP_KEYS` verbatim (same values, same order), creating two independent sources of truth for the workflow stage sequence. A future step added to one array but not the other would silently break workflow progression (IMPORT_STEP_ORDER drives access/commit/mapping/run services) or the union type derived from IMPORT_STEP_KEYS. Since `IMPORT_STEP_KEYS` is `as const` and already fixes ordering, derive the order array from it instead: `export const IMPORT_STEP_ORDER: readonly ImportStepKey[] = IMPORT_STEP_KEYS;`.

export const IMPORT_STEP_ORDER: readonly ImportStepKey[] = [
  'students',
  'rooms',
  'checkins',
  'transfers',
];


Multiple headerRow values cause the entire workbook to be re-parsed sequentially, one full parse per unique header row. These parses are independent of each other and can be slow for large files; run them in parallel with Promise.all instead of awaiting in a loop.

    const parsedByHeaderRow = new Map<number, ImportSheetData[]>();
    await Promise.all(
      headerRows.map(async (headerRow) => {
        parsedByHeaderRow.set(
          headerRow,
          await parseSheets(file.buffer, file.originalName, file.mimeType, headerRow),
        );
      }),
    );

    const parsedByHeaderRow = new Map<number, ImportSheetData[]>();
    for (const headerRow of headerRows) {
      parsedByHeaderRow.set(
        headerRow,
        await parseSheets(file.buffer, file.originalName, file.mimeType, headerRow),
      );
    }


`views` is keyed only by sheet name, so if two stages reference the same sheet name but declare different `headerRow` values, the later stage silently overwrites the earlier one. The stored `sheetsJson` then only contains the last version, while steps of the earlier stage also reference the same sheet name and will consume rows/headers parsed with the wrong header row. Key by `headerRow + sheetName` (or reject duplicate sheet names across different header rows) to avoid data mismatch.

          const view = parsedByHeaderRow
            .get(stage.headerRow ?? 1)
            ?.find((sheet) => sheet.name === sheetName);
          if (view) views.set(sheetName, view);


When the assigned sheets have no headers (`allowedHeaders.size === 0`), mapping validation is skipped entirely. A user-supplied `mappingByStep` referencing columns that don't exist (or the empty mapping suggested from `[]`) is then stored silently and only fails later at commit time with a less clear error. Consider validating the mapping whenever the user explicitly provided one, instead of skipping on empty header sets.

        for (const [field, header] of Object.entries(mapping)) {
          if (header && allowedHeaders.size > 0 && !allowedHeaders.has(header)) {


`Number('')` evaluates to 0, and `Number.isFinite(0)` is true. In a multipart form, if the client sends `conversationId` as an empty/blank field, a bogus `conversationId = 0` gets persisted on the import run instead of `undefined`. Guard against empty strings (and ideally trim) before coercing.

    const conversationId =
      body.conversationId !== undefined && body.conversationId.trim() !== ''
        ? Number(body.conversationId)
        : undefined;

    const conversationId =
      body.conversationId !== undefined ? Number(body.conversationId) : undefined;


`stages` is only checked with `Array.isArray` and then blindly cast to `ImportStageRequest[]`. A malformed element such as `{ sheets: 'abc' }` passes this cast but later throws a TypeError inside `expandStageSheets` (`stage.sheets.map` is not a function) or `worksheet.getRow` when `headerRow` is not a number, surfacing as an unhandled 500 instead of a user-facing 400. Validate each element (sheets/sheet must be strings, headerRow a positive integer, stepKey a known key) here before delegating to the service.

      try {
        const parsed = JSON.parse(body.stages) as unknown;
        if (!Array.isArray(parsed)) throw new Error('not array');
        stages = parsed as ImportStageRequest[];
      } catch {
        throw new BadRequestException('stages 参数格式错误');
      }


The JSON-parsing blocks for `stages`, `mapping`, and `settings` are near-duplicates; extract a small helper (parse optional JSON string + predicate) to avoid triplication. Also note the behavioral inconsistency: a non-array `stages` payload throws a 400, while a parsed non-object `mapping`/`settings` value is silently dropped (`mapping`/`settings` stay `undefined`), which can mask client-side mistakes. Make the failure behavior uniform.

    let mapping: Partial<Record<ImportStepKey, Record<string, string>>> | undefined;
    if (typeof body.mapping === 'string' && body.mapping.trim()) {


The endpoint accepts any uploaded file up to 10 MB without type filtering; `file.mimetype` is entirely client-controlled, and downstream `detectWorkbookKind` keys parsing off the filename extension/MIME. Consider adding a `fileFilter` (extension or magic-byte check for `.xlsx`/`.csv`) to reject unsupported uploads at the boundary and avoid wasted parsing of arbitrary payloads.

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


Using `@Res()` without `{ passthrough: true }` bypasses Nest's standard response pipeline, so global response interceptors/transformations (e.g. request logging or security-header middleware relying on the response) won't run for this endpoint, unlike the other routes in this controller. If such interceptors are expected globally, prefer `@Res({ passthrough: true })` and return the buffer, or explicitly set any missing headers here.

  async report(
    @Req() req: AuthenticatedRequest,
    @Res() res: Response,


Gender alias bug: '男性'/'女性' are mapped to 'male'/'female' on the lines above, but the validation whitelist is checked against the raw `genderRaw`. Since '男性'/'女性' are not in `['male', 'female', '男', '女']`, rows using these supported aliases are incorrectly rejected with '性别只能是男/女'. Validate against the normalized `gender` value instead, e.g. `if (gender && !['male', 'female'].includes(gender)) errors.push('性别只能是男/女');`

    if (genderRaw === '男' || genderRaw === '男性') gender = 'male';
    if (genderRaw === '女' || genderRaw === '女性') gender = 'female';
    if (gender && !['male', 'female'].includes(gender)) {
      errors.push('性别只能是男/女');
    }

    if (genderRaw === '男' || genderRaw === '男性') gender = 'male';
    if (genderRaw === '女' || genderRaw === '女性') gender = 'female';
    if (genderRaw && !['male', 'female', '男', '女'].includes(genderRaw)) {
      errors.push('性别只能是男/女');
    }


Data integrity gap: the transfer branch does not verify that `transferDate` is not earlier than the old occupancy's `checkInDate`. As written, `writeRow` will set `oldOccupancy.checkOutDate` to a date that can precede `checkInDate`, producing inconsistent occupancy records. Consider validating the date order when the old occupancy is found, e.g. `if (oldOccupancy && transferDate && transferDate < oldOccupancy.checkInDate) errors.push('换宿日期不能早于原宿舍入住日期');`

      const transferDate = parseDateValue(fields.transferDate);
      if (!transferDate) errors.push('换宿日期格式不正确（应为 YYYY-MM-DD）');
      normalized.transferDate = transferDate;


Permission mismatch: saving configuration (which persists sensitive credentials such as appSecret) is guarded by `integration:trigger` (触发集成). Per the RBAC presets, `integration:trigger` semantically means "trigger/run the integration", so any role granted trigger capability (e.g. to run syncs) can also read and overwrite the third-party credentials. There is no dedicated `integration:edit`/`integration:write` permission in `rbac-presets.ts`. Recommend adding a write permission (e.g. `integration:edit`) and using it here so that configuration changes are only allowed for users explicitly authorized to modify config.

  @Post()
  @RequirePermission('integration:edit')
  async saveConfig(@Body() body: SaveIntegrationConfigDto) {

  @Post()
  @RequirePermission('integration:trigger')
  async saveConfig(@Body() body: SaveIntegrationConfigDto) {


`testConnection` only requires the read-only `integration:read` permission, but it has side effects beyond reading: it performs a real outbound network call to the third-party (DingTalk) and pulls the stored raw appSecret from the DB (see `IntegrationConfigService.testConnection` -> `getRawConfig`) to authenticate. A role with only "view integration status" can therefore trigger outbound requests and cause the stored secret to be used externally. Consider guarding this endpoint with a write/trigger permission (`integration:trigger`) instead of a read-only one.

  @Post('test')
  @RequirePermission('integration:trigger')
  async testConnection(@Body() body: TestIntegrationConfigDto) {

  @Post('test')
  @RequirePermission('integration:read')
  async testConnection(@Body() body: TestIntegrationConfigDto) {


When the config for a type is not found, the endpoint returns HTTP 200 with `success: false` instead of a proper 404 status. This masks errors from API clients (they must inspect the body to detect failure) and is inconsistent with typical NestJS error semantics. Consider throwing `NotFoundException` (as `IntegrationConfigService.getAccessToken` already does) so the global exception filter produces a 404 response.

    if (!data) {
      throw new NotFoundException(`未找到 ${type} 的配置`);
    }

    if (!data) {
      return { success: false, message: `未找到 ${type} 的配置` };
    }


Silent data truncation: only the first 30 sheets, first 3000 non-empty rows per sheet, and first 60 columns are extracted, yet the resulting `ImportSheetData[]` is returned as if it were the complete workbook. Any larger import will silently lose rows/columns with no warning to the user. Consider detecting when a limit is hit (e.g., `worksheet.rowCount > MAX_ROWS_PER_SHEET` or remaining sheets exist) and throwing a clear `BadRequestException`/warning so users know the upload was truncated.

const MAX_SHEETS = 30;
const MAX_ROWS_PER_SHEET = 3000;
const MAX_COLS_PER_SHEET = 60;


MIME/extension classification has several misclassification paths: (1) any `text/plain` upload is accepted as CSV, so a random `.txt` file passes validation; (2) CSV is checked before XLSX, so an `.xlsx` file uploaded with a `text/csv`/`text/plain` MIME type is parsed as CSV and fails with a misleading error; (3) for `.xls` files uploaded as `application/octet-stream`, `detectWorkbookKind` returns `null` and the user gets the generic "仅支持 .xlsx / .csv" message instead of the more helpful `.xls` hint, because kind detection runs before the `.xls` check in `parseSheets`. Consider prioritizing the filename extension over MIME and handling `.xls` before kind detection.

  const isCsv =
    /\.csv$/i.test(originalName) ||
    /csv/i.test(mimeType) ||
    /text\/(csv|plain)/i.test(mimeType);


The catch block discards the original error and always throws 'Excel 文件解析失败，请检查文件格式' — this is misleading for CSV failures (message says "Excel 文件") and hides the root cause, making debugging hard. Suggest logging the original error (e.g., via a logger) and using kind-specific messages ('CSV 文件解析失败' / 'Excel 文件解析失败').

throw new BadRequestException('Excel 文件解析失败，请检查文件格式');


`Buffer.from(buffer)` creates a redundant copy since `buffer` is already a `Buffer`. Pass the buffer directly: `Readable.from(buffer)`.


No size guard is applied before ExcelJS fully loads/decompresses the workbook into memory (`.xlsx` is a zip archive). A very large file or a zip-bomb style payload can exhaust server memory during `workbook.xlsx.load` / `readXlsxSheetsFallback`. Consider rejecting files above a byte-size limit (e.g., checking `buffer.byteLength` up front) and, in the fallback, validating the zip entry count/sizes before reading them.

const workbook = new ExcelJS.Workbook();


The conditional `@Type` silently falls back to `WeComThirdConfigDto` for ANY non-DINGTALK value, including a missing/invalid `type` or any future member added to `IntegrationType`. If a third integration type is later added to the enum, its `config` will be silently transformed and validated against the WeCom schema instead of failing loudly. Prefer an explicit mapping over the implicit else-branch, e.g. a switch on the enum with an explicit default that throws/maps deliberately.

  @Type((options) => {
    switch (options?.object?.type) {
      case IntegrationType.DINGTALK:
        return DingTalkThirdConfigDto;
      case IntegrationType.WECOM:
        return WeComThirdConfigDto;
      default:
        return WeComThirdConfigDto;
    }
  })

  @Type((options) =>
    options?.object?.type === IntegrationType.DINGTALK
      ? DingTalkThirdConfigDto
      : WeComThirdConfigDto,
  )


`WeComThirdConfigDto` is an exact subset of `DingTalkThirdConfigDto` (which only adds `appId`), so the two classes duplicate the same required fields and validators. Extract the common `agentId`/`appSecret`/`corpId` fields into a shared base DTO and let both integration DTOs extend it, keeping validation rules defined once.

class ThirdConfigBaseDto {
  @IsString()
  @IsNotEmpty()
  agentId: string;

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

  @IsString()
  @IsNotEmpty()
  corpId: string;
}

export class WeComThirdConfigDto extends ThirdConfigBaseDto {}

export class WeComThirdConfigDto {
  @IsString()
  @IsNotEmpty()
  agentId: string;

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

  @IsString()
  @IsNotEmpty()
  corpId: string;
}


The `type` field of the returned config DTO is declared as a plain `string`, which weakens type safety even though `IntegrationType` (already defined in this file) is the canonical set of supported values. Type it as `IntegrationType` so callers get compile-time checks against the known integration types.

export interface ThirdConfigBaseDTO<T = unknown> {
  type: IntegrationType;

export interface ThirdConfigBaseDTO<T = unknown> {
  type: string;


The `_opUserId` parameter is declared (with default 'manager') but never sent in the request body — the API `topapi/attendance/getsimplegroups` requires `op_user_id`, and every other request in this module sends it. This request will likely fail with a DingTalk parameter error, or the parameter is simply dead code. Send it in the body: `JSON.stringify({ op_user_id: _opUserId, offset, size: 10 })` (and drop the underscore prefix).

body: JSON.stringify({ op_user_id: opUserId, offset, size: 10 }),

body: JSON.stringify({ offset, size: 10 }),


All `fetch` calls in this file lack `res.ok` checking and try/catch handling. If DingTalk returns a non-2xx status (or the response is not JSON, e.g. a gateway error page), `res.json()` will throw a raw `SyntaxError`/`TypeError`; network failures propagate as opaque errors instead of the user-friendly `ServiceUnavailableException` used elsewhere. There is also no timeout, so a hung connection blocks the request indefinitely. Recommend checking `res.ok` and wrapping in try/catch that rethrows a meaningful error (e.g. `ServiceUnavailableException`), and adding a timeout via `AbortSignal.timeout(...)`.

    const res = await fetch(
      `https://oapi.dingtalk.com/topapi/attendance/group/add?access_token=${token}`,
      {


`data.result!.id` uses a non-null assertion: if DingTalk returns `errcode === 0` without a `result.id` (or `result` is absent), this throws a `TypeError` after the success log rather than failing gracefully. Guard the value explicitly, e.g. `const id = data.result?.id; if (!id) throw new Error('钉钉创建考勤组成功但未返回 id');`


The DingTalk base URL `https://oapi.dingtalk.com` and the `access_token` query construction are duplicated across five requests, and `size: 10` / `offset += 10` are magic numbers that can drift apart. Extract a shared base-URL/token helper and a single page-size constant so endpoints and pagination stay consistent.


A self-closing empty cell (`<c r="B1"/>`, emitted by several xlsx writers) is not matched because this regex requires a closing `</c>`. When a self-closing cell sits between populated cells, `matchAll` pairs `<c r="B1"/>` with the *next* cell's `</c>`, so the following cell's value is parsed with the wrong column reference — data shifts left and trailing cells are lost. Handle the self-closing form explicitly (e.g. match `|<c\b([^>]*)\/>` as an empty-cell alternative and adjust group indexes), or split the row body on `<c\b` and parse each segment.

for (const cellMatch of rowMatch[1].matchAll(/<c\b([^>]*)\/?>([\s\S]*?)<\/c>/gs)) {


Same class of bug as the cell regex: a self-closing empty row (`<row r="N"/>`) is not matched because the pattern requires `</row>`. If such a row is followed by a populated row, the two tags are merged into a single match — the following row's cells get attributed to the wrong row and all subsequent `rowNumbers` shift. Also, the row's `r` attribute (real Excel row number) is ignored; it should be captured and carried through for accurate reporting.

for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/gs)) {


When a `t="s"` cell has no `<v>` child (or an empty one), `body.match(/<v>([^<]*)<\/v>/)?.[1] ?? ''` yields `''`, and `Number('')` is `0` — an integer — so the cell is silently filled with `sharedStrings[0]`. Only convert to a number when a `<v>` value actually exists; otherwise keep `''` (e.g. check the `<v>` match before calling `Number`).

value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';


`rowNumbers` is derived from the array index (`i + 1`) rather than the actual Excel row number. Since `sheetRowsFromXmlFallback` drops rows without cells and this loop skips all-empty rows, the reported numbers drift from the real worksheet rows, so import error messages can point users at the wrong row. Preserve the `r="..."` attribute from the row element during parsing and use it here.


`String.fromCodePoint` throws `RangeError` for code points above `0x10FFFF` or for lone surrogates (e.g. `�`, `�`). Cell values and sheet names come from user-uploaded files, so a crafted value can crash the whole import. Validate the parsed code point (same applies to the decimal `&#(...);` branch below) before converting, or wrap in try/catch.

    .replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) =>
      String.fromCodePoint(Number.parseInt(hex, 16)),
    )


The same prefix-stripping regex is re-inlined in `parseSharedStringsFallback` and `sheetRowsFromXmlFallback`. Hoist `stripPrefixes` to module scope and reuse it so all three call sites stay consistent.

  const stripPrefixes = (value: string): string =>
    value.replace(/<(\/?)([a-zA-Z][\w.]*):/g, '<$1');


`.replace(/<sheet\b/, '<sheet')` is a no-op (replaces the text with itself); `match[0]` is already the full `<sheet .../>` tag captured by the `<sheet\b[^>]*\/?>` pattern. Remove it.

const tag = match[0].replace(/\/?>$/, '>');

const tag = match[0].replace(/<sheet\b/, '<sheet').replace(/\/?>$/, '>');


This regex requires `Id` to appear before `Target` and both within a single `[^>]*` run. If a generator emits `Target` before `Id` (attribute order is not guaranteed in OOXML) the relationship is silently missed and the corresponding sheet is skipped without any error. Parse `Id` and `Target` from each `<Relationship ...>` tag independently instead of assuming a fixed attribute order.


The MAX_SHEETS / MAX_ROWS_PER_SHEET caps are only enforced later in `fallbackSheetsToImportSheets`; here every sheet in the workbook is fully parsed into `string[][]` and all shared strings are loaded before any limit is applied. For very large or hostile workbooks this can consume a lot of server memory. Consider applying the caps during parsing (or documenting the trade-off).


Timezone inconsistency in the fallback path: when `${normalized}+08:00` is unparseable, `new Date(normalized)` parses in the server's local timezone rather than UTC+8, contradicting the method's stated "统一按东八区解析" contract. If the runtime is outside UTC+8, beginTime/endTime/approvedAt will be off by hours. Prefer always appending the explicit offset and treating an unparseable value as an error instead of silently falling back to local time.

    const date = new Date(`${normalized}+08:00`);
    if (Number.isNaN(date.getTime())) {
      throw new Error(`钉钉日期格式无法解析: ${value}`);
    }
    return date;

    const date = new Date(`${normalized}+08:00`);
    return Number.isNaN(date.getTime()) ? new Date(normalized) : date;


No error handling around fetch/JSON parsing: network failures throw raw errors, a non-JSON body (e.g., a 5xx HTML error page) makes `res.json()` throw an unhelpful exception, and the HTTP status is never checked before trusting the body (only the DingTalk `errcode` is inspected). Wrap the request in try/catch, verify `res.ok`, and rethrow a user-friendly message containing the actual status/errmsg.

    if (!res.ok) {
      throw new Error(`钉钉接口请求失败: HTTP ${res.status}`);
    }
    const data = (await res.json()) as DingTalkGetUpdateDataResponse;
    if (data.errcode !== 0) {


The fetch call has no timeout (no AbortSignal). A hung DingTalk request will block the caller indefinitely and hold the rate-limit slot while waiting. Consider adding an abort timeout (e.g., AbortSignal.timeout) so upstream stalls fail fast.

    await this.context.rateLimit();
    const res = await fetch(
      `https://oapi.dingtalk.com/topapi/attendance/getupdatedata?access_token=${token}`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        signal: AbortSignal.timeout(10_000),
        body: JSON.stringify({


Two new DingTalk users sharing the same mobile that matches no existing student both fall into `creatable` and are inserted as two separate Student rows with an identical phone. This creates duplicate student records (and can blow up if `phone` has a unique constraint). Deduplicate `creatable` by mobile (e.g., first one created, later ones reported as conflicts) or enforce uniqueness before saving.

  const createdStudents = creatable.length
    ? await manager.save(
        Student,
        creatable.map((user) =>
          manager.create(Student, {
            name: user.name,
            phone: user.mobile,
            status: 'active',
            organizationId: host!.id,
          }),
        ),
      )


This sync performs several independent writes (update students, insert bindings, insert students, insert bindings again). If any later write fails, earlier writes are already committed, leaving partial/inconsistent state (e.g., a new student without its DingTalk mapping, or an updated profile without a binding). Wrap the whole operation in a transaction — or, since a caller-supplied `EntityManager` may already be inside one, document that callers must pass a transactional manager and guarantee atomicity.

if (updates.length) await manager.save(Student, updates);


The flow is check-then-insert without any DB-level protection: two concurrent sync calls for the same ding user (or same phone with no existing student) can both pass the "no mapping / no match" checks and create duplicate rows. Rely on a unique constraint on `StudentDingMapping.dingUserId` (and dedupe creation by mobile), or use an atomic upsert/insert-on-conflict and map constraint violations to conflicts.

  const mappings = await manager.find(StudentDingMapping, {
    where: { dingUserId: In(dingUserIds) },
  });


Duplicate `dingUserId` entries in `inputs` are silently dropped (first occurrence wins) and never reported in `conflicts` or `skipped`. Callers have no way to detect that input data was discarded. Consider recording a conflict for duplicates so the caller can correct the source data.

if (!users.has(dingUserId)) users.set(dingUserId, { dingUserId, name, mobile });


Phone-based matching queries all students regardless of `status`. A student who has left (inactive/archived) can be auto-bound to a DingTalk account and even have its name/phone overwritten by the Ding profile. Filter matches to active students (and consider requiring the names to agree) before auto-binding.

const matches = studentsByPhone.get(user.mobile) ?? [];


Only a length check is performed on `mobile`; malformed values such as "abc" or "1" pass validation and are later used to match or create students. Add a phone-format validation (digits only, reasonable length) and push a conflict for invalid values.

    if (mobile && mobile.length > 20) {
      conflicts.push({ dingUserId, name, reason: '手机号超过20个字符' });
      continue;
    }


DB errors (e.g., constraint violations on save) propagate as raw exceptions without context; this async function performs multi-step persistence with no try/catch or user-friendly error mapping. Wrap the persistence steps and rethrow with a meaningful message (e.g., which ding user/student failed), or document that the caller is responsible for translating errors.

if (creatable.length && !host) throw new Error('尚未配置本机构');


Nested ternary violates the project rule (nested ternary not allowed) and is hard to read. Since detail.type is always 'WECOM_SYNC'/'DINGTALK_SYNC' (or unknown), use a lookup map instead.

      type:
        { WECOM_SYNC: 'WECOM', DINGTALK_SYNC: 'DINGTALK' }[detail.type] ??
        detail.type,

      type: detail.type.includes('WECOM')
        ? 'WECOM'
        : detail.type.includes('DINGTALK')
          ? 'DINGTALK'
          : detail.type,


Because getTokenForTest always returns null for WECOM, every saveConfig('WECOM') silently stores enable=false (and testConnection always returns false). The user is told the save succeeded even though the config is permanently disabled and any future getAccessToken('WECOM')/sync will fail. If WeCom verification is genuinely not implemented, saveConfig should reject the request with a clear error instead of silently persisting a disabled config; otherwise testConnection/saveConfig should not treat the missing implementation as a failed verification.

      // 企微暂不实现，直接拒绝，避免静默保存为未启用状态
      throw new BadRequestException(`暂不支持 ${type} 平台的 token 验证`);

      // 企微暂不实现，返回 null
      return null;


ensureConfig is a non-atomic find-then-create with no unique constraint on IntegrationConfig.type (entity only indexes configId on the detail table). Concurrent requests (e.g. parallel saveConfig/getThirdConfig/setSyncStatus) can each see no row and insert duplicate 'THIRD' rows, making subsequent findOne nondeterministic. Also note that read-only methods (getThirdConfig/getRawConfig/getSyncStatus) mutate the DB by creating a row as a side effect of reading. Add a unique index on type and handle the duplicate-key case (or use an upsert).

    let config = await this.configRepo.findOne({ where: { type: 'THIRD' } });
    if (!config) {
      config = this.configRepo.create({ type: 'THIRD', isSync: false });
      await this.configRepo.save(config).catch(() =>
        // 唯一约束冲突时重查，避免并发创建重复行（需为 type 加唯一索引）
        this.configRepo.findOne({ where: { type: 'THIRD' } }),
      );
    }
    return config;

    let config = await this.configRepo.findOne({ where: { type: 'THIRD' } });
    if (!config) {
      config = this.configRepo.create({ type: 'THIRD', isSync: false });
      await this.configRepo.save(config);
    }
    return config;


On update, if the old content exists but cannot be parsed (or contains no appSecret), the exception is swallowed and the config is saved without an appSecret — the token test then fails and the previously working config is silently disabled (enable=false). The '首次配置必须提供 AppSecret' guard does not cover this path. When the secret can't be retained or provided on an update, throw instead of silently degrading the config.

      if (!finalConfig.appSecret) {
        let oldCfg: Record<string, unknown> = {};
        try {
          oldCfg = this.parseStoredConfig(existingDetail.content);
        } catch {
          oldCfg = {};
        }
        if (oldCfg.appSecret) {
          finalConfig.appSecret = oldCfg.appSecret;
        } else {
          throw new BadRequestException('更新配置时必须提供 AppSecret');
        }
      }

      if (!finalConfig.appSecret) {
        try {
          const oldCfg = this.parseStoredConfig(existingDetail.content);
          if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret;
        } catch {
          // ignore
        }
      }


saveConfig never reports verification failure: getTokenForTest swallows all errors (network timeout, wrong secret, etc.) and returns null, so the method completes successfully with enable=false and only a log line records the failure. The API consumer cannot distinguish 'saved and verified' from 'saved but disabled', and a transient DingTalk outage silently disables the platform config. Consider throwing (or returning a result object) when verified is false.

    const token = await this.getTokenForTest(request.type, finalConfig);
    const verified = !!token;
    if (!verified) {
      throw new BadRequestException(`配置验证失败，未保存: ${request.type}`);
    }


appSecret is persisted in plaintext inside the content JSON column (the entity comment itself notes '加密/明文'). Any DB leak exposes credentials. At minimum, encrypt the appSecret field before persisting and decrypt in getRawConfig. Additionally, the `verify` field stored here duplicates detail.enable and is never read anywhere (parseStoredConfig only returns the config portion), so it is dead data — same for the top-level `type` field.

    const content = JSON.stringify({
      config: finalConfig,
    });

    const content = JSON.stringify({
      type: request.type,
      verify: verified,
      config: finalConfig,
    });


DingTalk 的 listRecord 接口支持 offset/limit 分页（单页最多 50 条）并在响应中返回 hasMore。当前实现既未传分页参数、也未处理 hasMore，当考勤记录超过单页上限时会被静默截断，导致数据缺失。建议循环拉取直到 hasMore 为 false（或显式传入 limit 并校验返回条数）。

const records = data.recordresult ?? [];


时区处理不一致：workDate 按 UTC+8 计算，而 actualCheckTime 用 toISOString() 输出的是 UTC 时间。例如 UTC+8 的 00:30 打卡会被表示为前一天 16:30Z，与 workDate 无法对齐，下游按同一天关联打卡与考勤日期时会发生错位。建议统一用 dayjs(r.userCheckTime).utcOffset(8) 输出（如 dayjs.utc(r.userCheckTime).utcOffset(8).format(...)）。


fetch 后未检查 res.ok/HTTP 状态码，也未对 res.json() 做保护：网络错误、限流（429）、网关错误时 res.json() 可能抛异常或解析非 JSON 内容（如网关 HTML 页），原始异常（如 "Unexpected token"）会直接冒泡给调用方，错误信息不可读。建议先检查 res.ok，将响应按文本读取后容错解析 JSON，统一包装为友好的业务错误；同时为该请求设置超时（如 AbortSignal.timeout）。

    const res = await fetch(
      `https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,


access_token 直接拼接在 URL query 中，会出现在网关/代理访问日志、浏览器历史等位置，存在凭据泄露风险（钉钉旧版 oapi 若只支持 query 传参，也建议确认日志脱敏策略）。同时该固定第三方接口地址硬编码在业务代码中，建议将 API 地址、超时等收敛为常量或配置项统一管理。


startDate/endDate 未做非空与格式校验：空字符串会拼成 " 00:00:00" 直接透传给钉钉，非法格式（非 YYYY-MM-DD）也只会得到晦涩的远端错误；endDate 早于 startDate 同样未校验。建议在请求前校验日期格式与先后关系，并给出明确的本地错误信息。

const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`;


方法注释声明“7天内，最多50人”，但未对 userIds 数量和日期跨度做任何前置校验。超过 50 人或日期跨度超过 7 天时，钉钉 API 会直接返回错误或截断数据，问题只能等到运行时才暴露，且错误信息不直观。建议在调用前显式校验并给出清晰报错。

    if (userIds.length === 0 || userIds.length > 50) {
      throw new Error(`排班查询单次最多50人，当前 ${userIds.length} 人`);
    }
    if (toDate - fromDate > 7 * 24 * 60 * 60 * 1000) {
      throw new Error('排班查询时间跨度不能超过7天');
    }

  async queryScheduleByUsers(
    userIds: string[], fromDate: number, toDate: number, opUserId = 'manager',
  ): Promise<DingTalkScheduleResult[]> {


fetch 后直接 `res.json()` 并访问 `data.errcode`，缺少对 HTTP 状态码和网络异常的处理：非 2xx 响应（如 401/502）返回 HTML/非 JSON 时 `res.json()` 会抛晦涩的 SyntaxError；网络故障时 fetch 异常也会未加包装地向上抛。不符合异步错误处理规范，建议捕获异常并转换为对调用方友好的错误，同时校验 `res.ok`。queryScheduleByUsers 中的 fetch 存在同样问题。

    let res: Response;
    try {
      res = await fetch(
        `https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`,
        {

    const res = await fetch(
      `https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`,
      {


钉钉 API 端点 URL 直接硬编码在业务代码中（本文件两处，scheduleUsers 与 queryScheduleByUsers），不利于多环境/多租户配置与统一维护，也与调用逻辑耦合。建议将端点地址收敛到常量或配置（如环境变量/context 配置）中统一管理。


两个方法重复了同一套模板：isConfigured 校验 → getAccessToken → rateLimit → fetch → 解析 errcode → 抛错。可抽取为私有辅助方法（如 `private async request(path, body)`）统一处理令牌获取、限流、HTTP/errcode 错误转换，降低重复代码与后续不一致的风险。

    if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');
    const token = await this.context.getAccessToken();

    await this.context.rateLimit();


Concurrent token refresh race: the cache check + fetch + assignment in getAccessToken is not atomic. When multiple callers (syncAll, fetchOrgTree, sub-clients) invoke it concurrently right after expiry, each performs its own network token request, and a stale response can overwrite a newer token (last-write-wins). Cache the in-flight Promise so concurrent callers share a single refresh, e.g. `this.tokenPromise ??= this.doFetchToken(); const token = await this.tokenPromise; this.tokenPromise = null;`.

    if (
      this.accessToken &&
      this.accessTokenCredentialKey === credentialKey &&
      Date.now() < this.tokenExpiresAt - 60_000
    ) {
      return this.accessToken;
    }