同一学生在同一批次中，如果一行只填手机号、另一行只填学号（或一行同时填了学号+手机号），去重 key 和数据库查询都只按单一标识（phone 优先）进行，第二条记录无法命中第一条已创建的学生，会重复创建 Student（重复学号/手机号），破坏数据唯一性。建议去重 key 同时覆盖两个标识，且查询时 phone 查不到再按 studentNo 查。

    const dedupeKey = `${phone ? `phone:${phone}` : ''}|${studentNo ? `no:${studentNo}` : ''}`;
    if (seen.has(dedupeKey)) {
      skipped += 1;
      issues.push(`学生「${name}」与同一批次中的其他行手机号/学号重复`);
      continue;
    }
    seen.add(dedupeKey);

    let student = phone
      ? await studentRepo.findOne({ where: { phone } })
      : null;
    if (!student && studentNo) {
      student = await studentRepo.findOne({ where: { studentNo } });
    }

    const dedupeKey = phone ? `phone:${phone}` : `no:${studentNo}`;
    if (seen.has(dedupeKey)) {
      skipped += 1;
      issues.push(`学生「${name}」与同一批次中的其他行手机号/学号重复`);
      continue;
    }
    seen.add(dedupeKey);

    let student = phone
      ? await studentRepo.findOne({ where: { phone } })
      : await studentRepo.findOne({ where: { studentNo } });


历史入住记录（含 checkOutDate）缺少基本校验：checkOutDate 可能早于 checkInDate，也可能与学生在住（checkOutDate 为空）的现有记录时间段重叠，直接写入会生成自相矛盾/重叠的入住记录。建议在写入前校验 checkOutDate > checkInDate，并对与学生现有在住/历史记录的重叠做检查后跳过并记录问题。

    const checkOutDate = toDateString(row.checkOutDate);
    const isHistoricalRecord = Boolean(checkOutDate);


checkInDate 无效时（如格式错误）会被 ?? 静默降级为当天日期，而不是像 importTransfers 那样将无效日期作为问题行跳过并提示。这会让日期写错的记录被静默导入成“今天”，与换宿逻辑的校验行为不一致，容易掩盖脏数据。建议对无效 checkInDate 跳过并输出提示。

    const checkInDate = toDateString(row.checkInDate) ?? dayjs().utcOffset(8).format('YYYY-MM-DD');
    if (!toDateString(row.checkInDate)) {
      skipped += 1;
      issues.push(`学生「${name}」的入住日期格式无效（应为 YYYY-MM-DD）`);
      continue;
    }


用 `String(active.checkInDate)` 与 YYYY-MM-DD 字符串直接做字典序比较是脆弱的：一旦数据库驱动把 date 列水合为 Date 对象，String(Date) 会变成 "Wed Aug 05 2026 ..." 这类格式，比较结果会静默出错。建议统一用 toDateString 归一化后再比较。

if (transferDate < (toDateString(active.checkInDate) ?? '')) {

if (transferDate < String(active.checkInDate)) {


When `optional` is true, an explicitly-provided empty string (`''`) still throws `必须是字符串`, so callers such as `requireString(rawArgs.description, ..., true) || null` can never receive `''` — the `|| null` fallback for a missing/empty value is unreachable, and optional fields sent as `""` (common with LLM-generated payloads) cause spurious 400 errors. The `optional` branch is also only reached for `undefined`/`null`, which is inconsistent with the caller's expectation. Suggest treating an empty/whitespace string as absent when `optional` is true, and using a distinct `不能为空` message for empty strings (the current `必须是字符串` message is misleading for `''`):

  if (typeof value !== 'string') {
    throw new BadRequestException(`${label}必须是字符串`);
  }
  const trimmed = value.trim();
  if (!trimmed) {
    if (optional) return '';
    throw new BadRequestException(`${label}不能为空`);
  }

  if (typeof value !== 'string' || !value.trim()) {
    throw new BadRequestException(`${label}必须是字符串`);
  }
  const trimmed = value.trim();


`isPlainRecord` only excludes `null`/arrays, so it also returns true for `Date`, `RegExp`, `Map`, class instances, or any object with a custom prototype. Given the name and its use as a gate before `assertKeys`/key access, it is safer to require an ordinary object (or null-prototype object). This also rejects objects whose inherited properties could otherwise influence downstream `in` checks or spreads. For JSON-parsed input the current check is harmless, but hardening the guard is cheap:

export function isPlainRecord(value: unknown): value is Record<string, unknown> {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
  const proto = Object.getPrototypeOf(value);
  return proto === Object.prototype || proto === null;
}

export function isPlainRecord(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}


Cross-module coupling: the ai-chat DTO imports a domain constant from another module's DTO file (`ai-config/dto/ai-config.dto.ts`). This ties the two modules together at the type level and risks circular imports as modules evolve. A DTO file should not be the shared source of a business constant — move `REASONING_EFFORT_LEVELS` to a shared constants location (e.g., `common/constants` or an `ai-config/constants` file) and import it from there in both modules.

import { REASONING_EFFORT_LEVELS } from '../../ai-config/dto/ai-config.dto';


The `@IsUUID() clientRequestId` and `@IsOptional() @IsIn(REASONING_EFFORT_LEVELS) reasoningEffort` fields are duplicated verbatim across 5 DTOs in this file (SendMessageDto, RegenerateMessageDto, EditMessageDto, SubmitFormDto, SubmitReviewDto). Extract a shared base DTO (e.g., `MessageRequestBaseDto`) containing these common fields and have the message DTOs extend it, so validation rules stay consistent and changes are made in one place.


`values` is an unbounded object while every other request field in this file is length/size-capped. A client can submit an arbitrarily large `values` object, causing excess memory usage during validation and downstream processing. Consider adding a size limit (e.g., a custom validator capping the number of keys or the serialized size) to be consistent with the other bounded fields.

  @IsObject()
  values: Record<string, unknown>;


Inconsistent validation: `CreateConversationDto.title` has no `@IsNotEmpty()`, so an empty-string title is accepted on creation, while `UpdateConversationDto.title` rejects empty strings. If empty titles are invalid, add `@IsNotEmpty()` here too; otherwise align both DTOs to the same rule.

  @IsString()
  @MaxLength(100)
  title?: string;


Concurrency hazard: the transaction performs a read-modify-write of `review.sectionsJson` without any row lock or optimistic versioning. Two concurrent confirmations of the same review (e.g. `submitAll` running while the user confirms another section, or double-submit of the same section) both read the same `sectionsJson`, update disjoint sections, and then the last `manager.save(review)` overwrites the other's change — silently losing a section's `submitted` status. The affected section then appears pending and a re-submit re-runs `importOneSection`, risking duplicate imports. Use a pessimistic write lock or an optimistic version column on `AiReview`; the same pattern also exists in `markSectionFailed`.

    return await context.dataSource.transaction(async (manager) => {
      const review = await manager.findOne(AiReview, {
        where: { id: reviewId, userId },
        lock: { mode: 'pessimistic_write' },
      });

    return await context.dataSource.transaction(async (manager) => {
      const review = await manager.findOne(AiReview, {
        where: { id: reviewId, userId },
      });


Data-consistency issue: `resultSummary` is stored from `result.issues` before `section.issues` is merged, so issues accumulated from a previous failed attempt (`markSectionFailed` appends `导入失败：…` to `section.issues`) are never persisted into the stored summary. Downstream `parseStoredSectionResult`/`buildAggregateResult` read issues only from `resultSummary`, so the earlier failure message silently disappears from the final aggregate, while `section.issues` in `sectionsJson` still contains it. Merge issues first, then persist the merged list.

      section.issues = mergeIssues(section.issues, result.issues);
      section.resultSummary = JSON.stringify({ ...result, issues: section.issues, message });
      section.submittedAt = new Date().toISOString();

      section.resultSummary = JSON.stringify({ ...result, message });
      section.submittedAt = new Date().toISOString();
      section.issues = mergeIssues(section.issues, result.issues);


Fragile arithmetic / unsafe cast: `(value as { created: number }).created` is a compile-time-only assertion. If a caller ever passes a section result without a numeric `created`/`completed` field, `target[key].created += undefined` produces `NaN`, which then propagates into `buildAggregateMessage` (e.g. `成功导入学生 NaN 人`). Guard with `Number(...) || 0` (same for the `completed` branch) to make it robust.

    const created = Number((value as { created?: number }).created) || 0;
    target[key].created += created;

    const created = (value as { created: number }).created;
    target[key].created += created;


When a dependency type has no matching section at all, the user-facing message falls back to the raw type key (`「students」尚未导入，请先确认对应分表`), which is an internal identifier rather than a human-readable title. Consider mapping `dependencyType` to a display label (or deriving one) so the error message is understandable to end users.

    if (matches.length === 0) {
      return { step: -1, title: dependencyType };
    }


TOCTOU race + unhandled DB error: `roomNumber` has a unique constraint in the `rooms` table, but the existence check (`findOne`) and the insert (`save`) are not atomic. Two concurrent imports (e.g., double submit or parallel requests) can both pass the check, and the second `save` will throw an uncaught unique-violation error that aborts the entire batch (and rolls back rows already saved if the caller uses a transaction). Wrap the loop/save in try/catch and convert unique-violation into a skip + issue, or serialize imports for the same room numbers.

    const existing = await roomRepo.findOne({ where: { roomNumber } });
    if (existing) {
      skipped += 1;
      issues.push(`宿舍「${roomNumber}」已存在，未重复创建`);
      continue;
    }


TOCTOU race: `Student.phone` and `studentNo` have no unique constraint in the entity, so the `findOne`-then-`save` pattern is not atomic — concurrent import batches can both pass the existence check and silently create duplicate students. Additionally, there is no try/catch around the per-row operations, so any DB error (e.g., connection loss) aborts the whole batch with no user-friendly message. Consider adding a unique index / upsert and graceful per-row error handling.

    const existing =
      (phone
        ? await studentRepo.findOne({ where: { phone } })
        : null) ||
      (studentNo
        ? await studentRepo.findOne({ where: { studentNo } })
        : null);


Timezone-dependent off-by-one bug: the date is parsed as midnight `+08:00`, but `setDate`/`getFullYear`/`getMonth`/`getDate` use the server's *local* timezone. On a UTC server (or any zone west of +08:00), `nextDay('2026-08-09')` parses to 2026-08-08T16:00Z, so the local date is the 8th; adding one day then formatting yields `2026-08-09` — the same day, not the next day. Since this feeds `billingStartDate` for transfers, billing dates can be off by one day. Also, an invalid `date` string silently produces `NaN-NaN-NaN`. Use UTC-based getters (or a date library) and validate the input.

export function nextDay(date: string): string {
  const parsed = new Date(`${date}T00:00:00Z`);
  if (Number.isNaN(parsed.getTime())) return date;
  parsed.setUTCDate(parsed.getUTCDate() + 1);
  const year = parsed.getUTCFullYear();
  const month = String(parsed.getUTCMonth() + 1).padStart(2, '0');
  const day = String(parsed.getUTCDate()).padStart(2, '0');
  return `${year}-${month}-${day}`;
}

export function nextDay(date: string): string {
  const parsed = new Date(`${date}T00:00:00+08:00`);
  parsed.setDate(parsed.getDate() + 1);


Sequential per-row DB round trips: each row issues 1–2 `findOne` queries plus a `save` (and `importRooms` additionally saves beds), all awaited inside the loop. For AI-extracted batches that can contain hundreds of rows this becomes O(n) round trips. The in-batch `seen` dedupe can be computed purely in memory upfront, after which existing records can be checked with a single `In()` query and the remaining saves can be parallelized with `Promise.all` (same applies to `importRooms`).

  for (const row of section.rows) {
    const name = row.name === undefined || row.name === null ? '' : String(row.name).trim();


`text` maps to MySQL TEXT (max 65,535 bytes). Extracted text from documents (PDFs, Word files, OCR results) can easily exceed this limit, which will cause a `Data too long for column 'extracted_text'` error on insert/update. Consider `longtext` (or at least `mediumtext`) for this column.

@Column({ name: 'extracted_text', type: 'longtext', nullable: true })
  extractedText: string | null;

@Column({ name: 'extracted_text', type: 'text', nullable: true })
  extractedText: string | null;


`varchar(200)` will silently truncate long processing errors (e.g., stack traces or upstream API error messages), losing information needed for debugging. Use a `text` column or a larger length for `processing_error`.

@Column({ name: 'processing_error', type: 'text', nullable: true })
  processingError: string | null;

@Column({ name: 'processing_error', type: 'varchar', length: 200, nullable: true })
  processingError: string | null;


`integer` is a signed 32-bit INT (max ~2.1 GB). If an uploaded file ever exceeds 2 GB, `size` will overflow/corrupt the stored value. Use `bigint` for safety (note: MySQL `bigint` is returned as a string by the driver, so a `transformer` may be needed to keep `size` as a number).

@Column({ type: 'bigint', transformer: { to: (v: number) => v, from: (v: string) => Number(v) } })
  size: number;

@Column({ type: 'integer' })
  size: number;


The timeout/abort guarantee does not cover DNS resolution: `dns.lookup` is not abortable and is not wrapped in a timer, so if the system resolver hangs (misconfigured DNS, unreachable resolver), `pinnedPost` never settles and the `RequestTimeoutException` branch never triggers — the whole `stream()` can hang well beyond `config.timeoutMs`. The consumer's `signal` also cannot cancel the lookup. Consider racing the lookup against a timer (or checking `signal.aborted` and rejecting inside the callback and after it returns).

      lookup(parsed.hostname, { all: true, family: 0 }, (dnsError, addresses) => {
        if (signal.aborted) return reject(signal.reason ?? new Error('aborted'));


DNS failures are rejected as a plain `Error` with no `code`/`errno`, so the `ENOTFOUND`/`EAI_AGAIN` entries in `RETRYABLE_TRANSPORT_CODES` never match — transient DNS failures are therefore never retried (the check in `isRetryableTransportError` can only match the code set, and connection errors can't produce those codes since the request targets a resolved IP). Propagate the original `dnsError.code` (e.g. `EAI_AGAIN`) so the retry logic actually applies to flaky DNS.

        if (dnsError || !addresses?.length) {
          const err = new Error('DNS 解析失败') as NodeJS.ErrnoException;
          err.code = (dnsError as NodeJS.ErrnoException | null)?.code ?? 'ENOTFOUND';
          return reject(err);
        }

if (dnsError || !addresses?.length) return reject(new Error('DNS 解析失败'));


In this retryable-status branch the body is resumed without any `'error'` listener. If the upstream socket errors (e.g. ECONNRESET) while this stale error body is being drained, `IncomingMessage` emits `'error'` with no listener, which becomes an uncaught exception and can crash the process. Attach a no-op `error` handler, or `destroy()` the response instead of only resuming it.

        response.body.resume();
        response.body.on('error', () => undefined);


Dead code: the loop can only exit via `break` on a 2xx response — every non-2xx branch either throws (after retries are exhausted) or `continue`s, and `pinnedPost` either resolves or throws. So `if (!response)` is always false and `response.status` here is always 2xx; both blocks are unreachable. Remove them or restructure the loop so error/retry handling isn't duplicated after it.

if (response.status < 200 || response.status >= 300) {


SSRF hardening gaps: `allowPrivate` also skips the private-IP check entirely for `DNS_TRUSTED_HOSTS` — if DNS for these hosts is poisoned/compromised, requests (including the bearer API key) can be sent to internal addresses with no IP validation. Additionally the IPv4 range list misses `198.18.0.0/15`, multicast `224.0.0.0/4`, reserved `240.0.0.0/4` and TEST-NET (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`), and hex-encoded IPv4-mapped IPv6 (e.g. `::ffff:7f00:1`) bypasses the `::ffff:` check because `isIP` on the hex tail returns 0. Consider adding these ranges and validating the mapped tail as a numeric IPv4 address.

        const allowPrivate =
          process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true' ||
          DNS_TRUSTED_HOSTS.has(parsed.hostname);


The status transition (pending -> submitted) is not protected against concurrent submissions. Without a version column (@Version optimistic locking) or an atomic conditional update (e.g. `UPDATE ai_forms SET status='submitted', ... WHERE id=? AND status='pending'`), two simultaneous submit requests can both pass validation and overwrite `submittedValuesJson`/`submitted_at`, or double-count the submission. Consider adding a `@Version` column to this entity and having the submit service rely on it.

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

  @Column({ name: 'submitted_values_json', type: 'text', nullable: true })
  submittedValuesJson: string | null;


`submit_label` is limited to varchar(20), but this is a user-facing custom label (default '提交'). Longer labels will be silently truncated (or rejected in strict SQL modes), losing data the user explicitly entered. Consider widening the column (e.g. varchar(50)) or validating/clamping the length in the service before persistence.

  @Column({ name: 'submit_label', type: 'varchar', length: 20, default: '提交' })
  submitLabel: string;


The FK column `assistantMessageId` is non-nullable, but the relation property is declared as `AiMessage | null` and `@ManyToOne` does not state `nullable: false`. This makes the relation metadata inconsistent with the column definition and forces callers to handle a null that can never occur. Set `nullable: false` on the relation and type the property as `AiMessage`.

  @ManyToOne(() => AiMessage, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'assistant_message_id' })
  assistantMessage: AiMessage | null;


`sectionsJson` is stored as raw `text` and typed as plain `string`, even though this file defines the typed `AiReviewSection` contract. Every read/write of this field requires callers to manually `JSON.parse`/`JSON.stringify` with ad-hoc error handling, which is error-prone (malformed JSON will throw at runtime). Consider using a `ColumnTransformer` (or the `json` column type) so the entity field can be typed as `AiReviewSection[]` and serialization is centralized and validated in one place.

@Column({ name: 'sections_json', type: 'text' })
  sectionsJson: string;


The `@ManyToOne` relation defaults to `nullable: true`, which is inconsistent with the explicitly declared non-nullable `assistant_message_id` column (`@Column` without `nullable: true`). This can produce a schema where the FK relationship allows `NULL` while the column does not, and it weakens the domain invariant (a review must always reference an assistant message). Add `nullable: false` to the relation to keep the DDL and the model consistent.

@ManyToOne(() => AiMessage, { nullable: false, onDelete: 'CASCADE' })
  @JoinColumn({ name: 'assistant_message_id' })

@ManyToOne(() => AiMessage, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'assistant_message_id' })


`conversation_id` is a core query key for chat flows (fetching all reviews of a conversation), yet no index exists on it, while the composite index is on `(userId, status)` and another on `assistantMessageId`. If the typical access pattern is `WHERE conversation_id = ? (AND status = ?)`, these indexes won't serve it and scans will occur as review rows accumulate. Consider adding an index on `conversationId` (or a `(conversationId, status)` composite) if query patterns confirm it.

@Column({ name: 'conversation_id', type: 'integer' })
  conversationId: number;


This explicit index on `messageId` is redundant: InnoDB automatically creates an index on a foreign-key column when the `@ManyToOne`/`@JoinColumn` FK constraint (with `onDelete: 'CASCADE'`) is created. Keeping a second, separately named index on the same column wastes storage and adds write overhead for no query benefit. Consider removing `@Index('idx_ai_tool_runs_message', ['messageId'])` and relying on the FK's auto-created index.


Sensitive credential fields (`encryptedApiKey`, `apiKeyIv`, `apiKeyAuthTag`) are selected by default on every query. If this entity is ever returned directly in an API response, logged, or serialized, the ciphertext, IV, and auth tag will leak. Mark these columns with `select: false` so they are only loaded when explicitly requested, and never included in default queries/serialization.

  @Column({ name: 'encrypted_api_key', type: 'text', nullable: true, select: false })
  encryptedApiKey: string | null;

  @Column({ name: 'encrypted_api_key', type: 'text', nullable: true })
  encryptedApiKey: string | null;


The `provider` column is declared as a TS enum (`AiProvider`) but persisted as plain `varchar`, so the DB accepts arbitrary strings and TypeORM will silently return any invalid value as the enum type at runtime. Use `type: 'enum'` with the `enum` option to enforce valid values at the database level, or explicitly document why varchar was chosen.

  @Column({ type: 'enum', enum: AiProvider, default: AiProvider.DEEPSEEK })
  provider: AiProvider;

  @Column({ type: 'varchar', length: 50, default: AiProvider.DEEPSEEK })
  provider: AiProvider;


Audit logging runs *after* the config has already been persisted, and the hostname is extracted with an unguarded `new URL(config.baseUrl).hostname`. If URL parsing throws or the operation-logs insert fails, this handler returns 500 even though the save already succeeded — the client believes the save failed and may retry, causing duplicate writes/audits. Extract the hostname defensively and make the audit write best-effort so it cannot invalidate an already-successful operation.

    const config = await this.service.saveConfig(body);
    try {
      let hostname = '-';
      try { hostname = new URL(config.baseUrl).hostname; } catch { /* keep '-' */ }
      await logAudit(this.opLog, req, {
        module: 'ai-config', action: 'save', targetId: config.id, targetType: 'AiConfig', detail: `provider=${body.provider} host=${hostname} model=${body.defaultModel ? 'configured' : 'not-set'}`,
      });
    } catch {
      // audit-log failure must not turn a successful save into an error response
    }

    const config = await this.service.saveConfig(body);
    await logAudit(this.opLog, req, {
      module: 'ai-config', action: 'save', targetId: config.id, targetType: 'AiConfig', detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'}`,
    });


Same audit-after-mutation hazard: the API key has already been cleared from the DB when `logAudit` runs. If the audit insert fails (e.g. operation-logs table unavailable), the client receives a 500 although the key was successfully cleared — misleading and may cause confusing retries. Wrap the audit write in try/catch or log-and-continue.

    const data = await this.service.clearKey();
    try {
      await logAudit(this.opLog, req, {
        module: 'ai-config', action: 'clear-key', targetType: 'AiConfig', detail: `keySource=${data.keySource}`,
      });
    } catch {
      // audit-log failure must not turn a successful clear into an error response
    }

    const data = await this.service.clearKey();
    await logAudit(this.opLog, req, {
      module: 'ai-config', action: 'clear-key', targetType: 'AiConfig', detail: `keySource=${data.keySource}`,
    });


Same audit-after-mutation pattern as the other handlers: a successful connection test is followed by `logAudit`; if that audit write fails, the endpoint returns an error even though the test succeeded. Consider making the audit write best-effort so logging failures cannot override the real operation result.

    const result = await this.service.testConnection(body);
    await logAudit(this.opLog, req, {
      module: 'ai-config', action: 'test', targetType: 'AiConfig', detail: `provider=${body.provider ?? '-'} success=${result.success} latency=${result.latencyMs ?? '-'}`, status: result.success ? 'success' : 'failure',
    });


Nested ternary expressions are used to build the connection message, which violates the review rule that nested ternaries are not allowed and reduces readability. Refactor into explicit if/else branches.

      let message: string;
      if (modelAvailable) {
        message = `连接成功，目标模型 "${effectiveDefaultModel}" 可用`;
      } else if (effectiveDefaultModel) {
        message = '连接成功，但未找到目标模型';
      } else if (models.length > 0) {
        message = `连接成功，可用模型 ${models.length} 个`;
      } else {
        message = '连接成功，但未返回可用模型';
      }

      const message = modelAvailable
        ? `连接成功，目标模型 "${effectiveDefaultModel}" 可用`
        : effectiveDefaultModel
          ? '连接成功，但未找到目标模型'
          : models.length > 0
            ? `连接成功，可用模型 ${models.length} 个`
            : '连接成功，但未返回可用模型';


This error-message mapping uses nested ternaries (not allowed by the review rules) and, worse, the '禁止重定向' branch maps to the exact same string as the fallback branch, making the extra branch dead logic. Simplify with if/else and drop the redundant branch.

    let message = '连接失败，请检查 Base URL';
    if (err instanceof Error && err.message === '连接超时') {
      message = '连接超时';
    } else if (err instanceof Error && err.message === '响应过大') {
      message = '响应过大';
    }

    const message =
      err instanceof Error
        ? err.message === '连接超时'
          ? '连接超时'
          : err.message === '响应过大'
            ? '响应过大'
            : err.message === '禁止重定向'
              ? '连接失败，请检查 Base URL'
              : '连接失败，请检查 Base URL'
        : '连接失败，请检查 Base URL';


testConnection and fetchModels duplicate nearly the same flow: baseUrl validation, DNS SSRF check, API-key resolution, the /models HTTP call, and HTTP-status classification (401/403, >=500, >=400, non-JSON). Extract this into a shared helper so the two entry points cannot diverge in behavior (e.g., different error messages for the same failure).

    const message = err instanceof BadRequestException ? err.message : '请求参数无效';
    return { success: false, models: [], message };
  }

  // DNS SSRF check


The JSON-parse failure path saves the config manually and returns early, duplicating the shared save block at the end of testConnection. Any future change to the final save logic (e.g., adding a field) will silently diverge on this path. Build the failure `result` here and fall through to the single save at the end instead of saving and returning early.

      } catch {
        config.lastTestedAt = new Date();
        config.lastTestLatencyMs = latencyMs;
        config.verified = false;
        await context.save(config);
        return {


resolveApiKey is declared to accept `AiConfig | null`, but every call site in this file passes a non-null config returned by getOrCreateConfig(). Either drop `null` from the parameter type or add a null guard at call sites so the type accurately reflects the contract.

  resolveApiKey(config: AiConfig | null): {
    plaintext: string | null;
    source: 'database' | 'environment' | 'none';
  };


synchronize defaults to `true` whenever DB_SYNCHRONIZE is not set, including production, and the loose `!== 'false'` comparison treats any value other than the literal string 'false' (e.g. '0', 'no', '', 'TRUE') as enabling auto-sync. Combined with the large entity list and the migrations array defined just above, TypeORM auto-sync can silently alter/drop columns and conflict with the migration history, risking real data loss. Recommend defaulting to `false` in production and parsing strictly, e.g.: synchronize: config.get('DB_SYNCHRONIZE', process.env.NODE_ENV === 'production' ? 'false' : 'true') === 'true'

synchronize: config.get('DB_SYNCHRONIZE', process.env.NODE_ENV === 'production' ? 'false' : 'true') === 'true',

synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',


ConfigService.get returns environment variables as strings, so when DB_PORT is set (e.g. DB_PORT=3306) the value passed to the mysql driver is a string despite the `number` type parameter. Coerce it explicitly to avoid connection/type inconsistencies.


The allMigrations array is declared in the middle of the import block; the subsequent import statements for feature modules come after executable code. Although ESM import hoisting makes this work, it hurts readability and import-order linting. Move all import statements to the top of the file and place the migration array after them.


The throttling window/limit (ttl: 60000, limit: 100) are hardcoded business values. Per the project rules, business numbers should not be hardcoded - read them from configuration/env so each deployment can tune rate limits without a code change.


`datetime` in MySQL has second precision only (no fractional seconds). The composite index `idx_ai_messages_conversation_created` on (conversationId, createdAt) is presumably used for conversation history pagination, but multiple messages created within the same second will share an identical `createdAt`, making `ORDER BY createdAt` / cursor-based pagination (`createdAt < x`) non-deterministic — rows can be skipped or duplicated. Since this app persists multiple messages in quick succession (e.g., streaming/tool-run splits), consider `datetime(6)` (or `datetime(3)`) for both timestamp columns, and/or add `id` as a secondary sort key in pagination queries.

  @CreateDateColumn({ name: 'created_at', type: 'datetime(6)' })
  createdAt: Date;

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


The DB-level default for `status` is `'completed'`, but the actual initial lifecycle state of a message is `'pending'` (the streaming flow inserts messages with `status: 'pending'`, and `pending` is the first member of `AiMessageStatus`). Any insert that omits an explicit `status` (e.g., raw SQL, bulk insert, partial entity save) will silently record an unfinished message as `'completed'`, corrupting the conversation state. Align the default with the initial state (`'pending'`) or drop the default so `status` is always set explicitly.

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

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


Session label mismatch causes the entire detail matrix to render empty. The stored `session` values are English keys — `morning_reading`/`morning`/`afternoon`/`evening_study`/`night_check` (see `@IsIn` in attendance.dto.ts and `mapLessonScheduleTimeToSession` in attendance-time.ts) — but here the table columns are hardcoded Chinese labels `['上午','下午','晚自习']`. `cellMap` is keyed by `r.session`, so `cellMap.get(session)` never matches and every cell always shows `-`, silently dropping all detail data from the report. Additionally the system defines 5 session slots while only 3 columns are rendered. Suggest mapping the real session keys to display labels (covering all 5), e.g. build the columns from `{ morning_reading: '早读', morning: '上午', afternoon: '下午', evening_study: '晚自习', night_check: '晚查' }` or from the actual keys present in the records.


The heading says "最近30条" (last 30 records), but `dates.slice(-30)` limits the number of distinct *dates*; each date can contain up to 3 sessions (or more, once the session mapping is fixed), so the table can show far more than 30 records. Make the label/behavior consistent, e.g. rename to "最近30天" or slice the records themselves.


The per-status counting logic is duplicated: `buildAttendance` filters records 4 times (`r.status === 'present'/'absent'/'late'/'leave'`) and `renderAttendanceBar` recomputes the same counts via `statuses.map(...)`. If a new status is added, the two places can drift. Consider extracting a shared helper (e.g. `countByStatus(records)` returning a partial counts object) used by both.

  const statuses = ['present', 'absent', 'late', 'leave'] as const;
  const counts = statuses.map((s) => records.filter((r) => r.status === s).length);


The validation field definitions (`provider`, `baseUrl`, `apiKey`, `timeoutMs`, `reasoningEffort`) are duplicated almost verbatim across `SaveAiConfigDto`, `TestAiConfigDto`, and `FetchModelsDto`. This makes it easy for the DTOs to drift out of sync (e.g., adding a new field to one but forgetting the others). Consider extracting a shared base DTO (e.g., `AiConfigConnectionFieldsDto`) holding the common optional-validated fields and extending it in all three DTOs.

export class TestAiConfigDto {
  @IsOptional()
  @IsIn(PROVIDERS)
  provider?: AiProvider;

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

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

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

  @IsOptional()
  @IsInt()
  @Min(1000)
  @Max(120000)
  timeoutMs?: number;

  @IsOptional()
  @IsIn(REASONING_EFFORT_LEVELS)
  reasoningEffort?: string | null;
}


`reasoningEffort` is declared as `string | null` even though the `@IsIn(REASONING_EFFORT_LEVELS)` decorator restricts runtime values to the exported union. This type gap lets callers compile invalid values (e.g., `'extreme'`) that will then fail at runtime with a validation error. Type it as `(typeof REASONING_EFFORT_LEVELS)[number] | null` to get compile-time enforcement matching the validator. The same applies to `AiConfigResponseDto.reasoningEffort` and `AiRuntimeConfig.reasoningEffort` below.

  @IsOptional()
  @IsIn(REASONING_EFFORT_LEVELS)
  reasoningEffort?: string | null;
}


Validation gap / logic inconsistency: when the client omits `enabled` (it is optional in `SaveAiConfigDto`), the `else` branch force-sets `config.enabled = true`, but the required-key/model check only runs when `dto.enabled === true`. As a result, a PUT that only updates e.g. the API key (or `defaultModel: ''`, which is coerced to null) will silently enable AI with no key/model, producing a state that later throws '未配置 API Key' / '未配置默认模型' in `getRuntimeConfig`. Validate against the final `config.enabled` instead.

    if (config.enabled) {
      const { plaintext } = this.resolveApiKey(config);
      if (!plaintext) throw new BadRequestException('启用 AI 服务前必须配置 API Key');
      if (!config.defaultModel?.trim()) {
        throw new BadRequestException('启用 AI 服务前必须配置默认模型');
      }
    }

    if (dto.enabled === true) {
      const { plaintext } = this.resolveApiKey(config);
      if (!plaintext) throw new BadRequestException('启用 AI 服务前必须配置 API Key');
      if (!config.defaultModel?.trim()) {
        throw new BadRequestException('启用 AI 服务前必须配置默认模型');
      }
    }


Nested ternary is prohibited by the project review rules. Extract this masking logic into a small helper or a plain if/else so the display logic stays readable, e.g. compute `maskedApiKey` before building the response object.

      maskedApiKey: config.keyLast4
        ? this.buildMaskedKey(config.keyLast4)
        : source !== 'none'
          ? '••••'
          : null,


`getConfig()` calls `resolveApiKey(config)`, which throws `InternalServerErrorException` whenever the stored ciphertext/IV/authTag cannot be decrypted. A single corrupted DB key then makes the entire GET config endpoint (and hence the settings UI) return 500 with no way to inspect or fix the rest of the config through that endpoint (only `clearKey` recovers, and it is not reachable from a UI that depends on GET). Consider catching the decryption failure here and reporting `hasApiKey`/`hasDatabaseKey` as false (or an explicit 'decrypt-failed' state) instead of failing the whole request.

    const config = await this.getOrCreateConfig();
    const { source } = this.resolveApiKey(config);


The full national ID (身份证号) is rendered unmasked on the report cover. This is highly sensitive PII; even though the report footer labels it confidential, such reports can be printed, emailed, or leaked. Recommend masking it (e.g., keep first 6 + last 4 digits: `123456********1234`) unless the full number is strictly required for archival use, and if so, gate the full value behind access control rather than including it in the default cover layout.

学号: ${esc(student.studentNo || '-')}
身份证号: ${esc(student.idNumber || '-')}


The loose inequality `!= null` is prohibited by the review rules; use strict equality or the nullish coalescing operator. Note also that these are `decimal` DB columns (TypeORM/MySQL may return them as strings at runtime), and they are interpolated into HTML without `esc()`, which is inconsistent with the other fields — escape them for safety (e.g., `esc(String(result.cultureFinalScore ?? '')) || '-'`).

文化课成绩${esc(String(result.cultureFinalScore ?? '')) || '-'}

专业课成绩${esc(String(result.professionalFinalScore ?? '')) || '-'}

文化课成绩${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}

专业课成绩${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}


Both `buildLearning` and `buildResult` duplicate the same `sectionFrame(sectionHeader(...) + title-row)` wrapper boilerplate. Consider extracting a shared helper (e.g., `archiveSection(title, body, source?)`) to avoid duplication and keep the two builders consistent.

return sectionFrame(`
      ${sectionHeader('学情记录')}

${esc(now)} · 系统生成

学情记录


The category split uses independent `includes` substring checks, so any enrollment whose `courseCategory` contains BOTH '文化' and '专业' (e.g. "文化专业班") will be rendered in both the culture table and the professional table, and excluded from "其他报读" as well — the same record appears twice in the report. Make the classification mutually exclusive (e.g. culture → else-if professional → else other, or check other first) so each enrollment is rendered exactly once.

  const cultureEnrollments = enrollments.filter(
    (e) => e.courseCategory && e.courseCategory.includes('文化'),
  );
  const profEnrollments = enrollments.filter(
    (e) => e.courseCategory && e.courseCategory.includes('专业'),
  );


`enrollments` is dereferenced without a null guard (`enrollments.length`). Although the parameter is typed as `StudentEnrollment[]`, adding a defensive check (e.g. `if (!enrollments || enrollments.length === 0) return '';` or `if (!enrollments?.length)`) would prevent a runtime crash if a caller ever passes `null`/`undefined`.

export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string {
  if (!enrollments?.length) return '';

export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string {
  if (enrollments.length === 0) return '';


The `.toc-row` grid defines only 2 columns (`48px 1fr`) but the row carries 3 elements (`.toc-index`, `.toc-name`, `.toc-page`). With auto-placement, `.toc-page` will be placed on a second grid row instead of the same line, breaking the TOC row layout (page number wraps below). Add a third column, e.g. `grid-template-columns: 48px 1fr 40px`, or place the page number inside the name cell in the HTML.

      .toc-row {
        display: grid; grid-template-columns: 48px 1fr 40px; align-items: center;
        height: 47px; border-bottom: 1px solid #cfe0f2;
      }

      .toc-row {
        display: grid; grid-template-columns: 48px 1fr; align-items: center;
        height: 47px; border-bottom: 1px solid #cfe0f2;
      }


Two problems with the cover page: (1) `.section-cover` has no `position: relative`, so absolutely positioned children like `.frame` (and `.watermark`/`.footer`) resolve against the nearest positioned ancestor (`.section`) or the page's initial containing block, so `inset: 14mm` on `.frame` will not frame the cover as intended and may get clipped by `overflow: hidden`. (2) When used as `<section class="section section-cover">`, the fixed `height: 297mm` is the *content* height while `.section` adds `14mm` top / `10mm` bottom padding, making the cover section 321mm tall — taller than the A4 page — so it spills ~24mm onto a second (mostly blank) page in print. Fix by making the cover exactly one page tall (e.g. set `.section-cover { height: 273mm; padding: 0; position: relative; }` or `height: 297mm` on `.section` with `box-sizing: border-box` and no vertical padding on the cover).

      .section-cover {
        position: relative; height: 273mm; padding: 0; overflow: hidden;
        page-break-after: always; break-after: page;
      }

      .section-cover {
        height: 297mm; overflow: hidden;
        page-break-after: always; break-after: page;
      }


In NestJS, throwing a generic `Error` results in HTTP 500. "Student not found" is a client-side condition and should return 404 via `NotFoundException` from `@nestjs/common`, otherwise callers cannot distinguish a missing student from a real server failure.

if (!student) throw new NotFoundException('学生不存在');


This query loads the student's entire learning-record history, but `buildLearning` only displays the first 15 records. Likewise `attendanceRepo.find`/`examRepo.find`/`enrollmentRepo.find` load full history with no cap, which for long-term students means thousands of rows fetched into memory and embedded into one HTML string. Add `take: 15` to the learnings query (and consider caps for attendance/exams) at the query level instead of fetching everything.

this.learningRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),


进步幅度 (improvement) is computed from the array order of `cultureExams` (first element vs last element), but the metric is labeled '首考 → 末考变化' (first exam → last exam). The function silently depends on the caller passing exams sorted by examDate — it currently works because archive-report.service.ts queries with `order: { examDate: 'ASC' }`, but nothing enforces or documents this contract here. If any future caller (or a refactor of this function) passes unsorted exams, the reported improvement becomes wrong without any visible error. Consider sorting `cultureExams` by `examDate` inside this function before computing the delta, so the metric is correct regardless of input order.

  const scored = cultureExams
    .filter((e) => e.score != null)
    .sort((a, b) => (a.examDate ?? '').localeCompare(b.examDate ?? ''));
  let improvement = '—';
  if (scored.length >= 2) {
    const first = scored[0].score!;
    const last = scored[scored.length - 1].score!;
    improvement = (last - first).toFixed(1);
  }

  const sortedScores = cultureExams
    .map((exam) => exam.score)
    .filter((score): score is number => score !== null && score !== undefined);
  let improvement = '—';
  if (sortedScores.length >= 2) {
    const first = sortedScores[0];
    const last = sortedScores[sortedScores.length - 1];
    improvement = (last - first).toFixed(1);
  }


平均分 includes culture exams whose `score` is null, counting them as 0 via `(e.score ?? 0)` while still dividing by the full `cultureExams.length`. This skews the reported average downward whenever a culture exam record lacks a score. Note this is inconsistent with `renderScoreTrendChart`, which filters `e.score != null`. Filter to scored exams before averaging.

  const scoredExams = cultureExams.filter((e) => e.score != null);
  const avgScore =
    scoredExams.length > 0
      ? (
          scoredExams.reduce((sum, e) => sum + e.score!, 0) /
          scoredExams.length
        ).toFixed(1)
      : '-';

  const avgScore =
    cultureExams.length > 0
      ? (
          cultureExams.reduce((sum, e) => sum + (e.score ?? 0), 0) /
          cultureExams.length
        ).toFixed(1)
      : '-';


最佳/均分 use `e.score ?? 0` over all `subExams`, so exams with a null score are treated as 0. If a subject has any score-less records, the average is distorted (and if all records are null, the header renders '最佳 0 · 均分 0.0'). Filter out null scores before computing `best`/`avg`, consistent with the overview metrics.

    const scored = subExams.filter((e) => e.score != null);
    const best = scored.length > 0 ? Math.max(...scored.map((e) => e.score!)) : 0;
    const avg = scored.length > 0
      ? (scored.reduce((sum, e) => sum + e.score!, 0) / scored.length).toFixed(1)
      : '-';

    const best = Math.max(...subExams.map((e) => e.score ?? 0));
    const avg = (
      subExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / subExams.length
    ).toFixed(1);


Loose equality `!=` is used instead of strict `!==` (also in `renderScoreTrendChart`'s `e.score != null` filter and in `buildExamDetail`'s table rows). Per project rules `==`/`!=` are prohibited. Since `score`, `classAvg` and `rank` are typed `number | null` (not `undefined`), `!== null` is equivalent and stricter here.

${e.score !== null ? e.score : '-'}
                ${e.classAvg !== null ? e.classAvg : '-'}
                ${e.rank !== null ? e.rank : '-'}

${e.score != null ? e.score : '-'}
                ${e.classAvg != null ? e.classAvg : '-'}
                ${e.rank != null ? e.rank : '-'}


All fields are optional, so an empty body `{}` passes validation. In `archive.service.ts#upsertProfile`, when no profile exists the code runs `profileRepo.create({ ...dto, studentId })`, which would insert an empty record; and because `@IsOptional()` also allows `null` through, a payload like `{ targetCollege: null }` will overwrite existing data via `Object.assign(profile, dto)`. Recommend requiring at least one field (custom validator) for upsert, or splitting into a CreateDto with required fields plus a PartialType-based UpdateDto.


Same all-optional issue as `UpsertProfileDto`: an empty body passes validation and `archive.service.ts#upsertResult` will create an empty result record for a new student, while explicit `null` values (allowed by `@IsOptional()`) can erase existing result fields via `Object.assign(result, dto)`. Consider requiring at least one field or using a dedicated CreateDto/UpdateDto split.


`PartialType` makes every field optional, so an empty body `{}` passes validation and `updateEnrollment` becomes a silent no-op (`Object.assign(entity, dto)` with no keys). If this endpoint is meant to be a full update (PUT semantics), empty payloads should be rejected; if PATCH semantics are intended, consider validating that the body is non-empty to avoid silent no-ops. The same applies to `UpdateExamScoreDto` and `UpdateLearningRecordDto`.

export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {}


`file` will be `undefined` when the multipart request does not include a `file` field (or the field name differs). In that case, before the service's friendly `addAttachment` validation (which throws `BadRequestException('请选择附件文件')`) is ever reached, the audit-detail callback dereferences `file.originalname` and raises an unhandled TypeError -> HTTP 500. Guard the file first (e.g., `if (!file) throw new BadRequestException(...)`) or use `ParseFilePipe` with `FileTypeValidator`/`MaxFileSizeValidator` so a missing/invalid file is rejected before the handler runs.

module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file?.originalname || ''} (${category || 'other'})`,

module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file.originalname} (${category || 'other'})`,


`FileInterceptor` is used without any `limits` and defaults to multer memory storage, so the entire uploaded file is buffered in RAM with no size cap (multer's default file size limit is unlimited). A malicious/large upload can exhaust server memory (DoS), and arbitrary file types are accepted. Configure `limits: { fileSize: ... }` and validate the content type / file signature before persisting.

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

  @UseInterceptors(FileInterceptor('file'))
  async uploadAttachment(


Stored XSS risk: `mimeType` is stored verbatim from the client-supplied upload header (see `addAttachment` in the service) and later served with `Content-Disposition: inline` from the same origin. An attacker can upload a `.html`/`.svg` file claiming `text/html` and it will render/execute in the app origin when downloaded. Use `attachment` disposition (forces download instead of inline rendering) and/or whitelist safe content types when serving.

    res.setHeader('Content-Type', mimeType);
    res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`);


`fs.createReadStream(fullPath)` has no `error` listener. Although the service checks `fs.existsSync` beforehand, that is a TOCTOU race (file can be deleted/rotated between the check and the open) and read errors can still occur. An unhandled 'error' event on the stream will throw and can crash the process, or leave the client connection hanging. Attach `stream.on('error', ...)` that ends the response with an error status.

    const stream = fs.createReadStream(fullPath);
    stream.on('error', (err) => {
      if (!res.headersSent) res.status(500);
      res.end();
    });
    stream.pipe(res);

    const stream = fs.createReadStream(fullPath);
    stream.pipe(res);


Audit logs write the full request DTO via `JSON.stringify(dto)` for profile, enrollment, exam-score, learning-record, and result mutations. These DTOs contain student personal data / scores (PII), and `detail` is persisted to the operation-logs table and may end up in other log sinks. Consider logging only a summary (field names changed or non-sensitive attributes) to comply with data minimization.

module: '学生档案', action: '更新档案信息', targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto),


The audit log write runs after the DB mutation has already succeeded in create/update/remove. If `logService.log` throws (e.g., operation-log table failure), the request returns a 500 even though the device was created/updated/deleted — the client may retry and hit duplicate-SN conflicts or otherwise see inconsistent state. Wrap the logging in a try/catch (or make it non-blocking) so audit failures don't break the main operation; consider extracting this duplicated log block into a private helper since it's repeated identically in all three mutation methods.

    const result = await this.service.create(dto);
    try {
      await this.logService.log({
      userId: req.user?.id,

    const result = await this.service.create(dto);
    await this.logService.log({
      userId: req.user?.id,


The `status` query param is unvalidated user input that flows directly into the TypeORM where clause (`where.status = query.status`). Any arbitrary string (e.g. `?status=foo`) is accepted and just yields an empty result, while a repeated param can arrive as a `string[]` and cause a DB error. Similarly, `Number(classroomId)` silently produces `NaN` for non-numeric input. Validate/whitelist these query params (e.g. ParseEnumPipe / ParseIntPipe or explicit checks) before querying, so the API contract is enforced instead of relying on downstream falsy filtering.

    const parsedStatus =
      status === AttendanceDeviceStatus.ACTIVE || status === AttendanceDeviceStatus.DISABLED
        ? status
        : undefined;
    const parsedClassroomId = classroomId && Number.isFinite(Number(classroomId)) ? Number(classroomId) : undefined;
    return this.service.findAll({
      classroomId: parsedClassroomId,
      status: parsedStatus,
    });

    return this.service.findAll({
      classroomId: classroomId ? Number(classroomId) : undefined,
      status,
    });


classroomId has no lower-bound validation: @IsInt() accepts 0 and negative values, so an invalid classroom ID (e.g., 0 or -1) passes DTO validation and only fails later at the DB foreign-key layer (producing a 500 instead of a 400). Add @IsPositive() (or @Min(1)) to both CreateAttendanceDeviceDto and UpdateAttendanceDeviceDto.

  @IsInt()
  @IsPositive()
  classroomId: number;


notes has no length limit, while the analogous location field is capped at @MaxLength(200). Since this value comes from user input and is persisted to the DB, unbounded notes allows oversized payloads to be accepted. Add @MaxLength (e.g., 1000 or a value aligned with the intended content) for consistency and input-size control.

  @IsOptional()
  @IsString()
  @MaxLength(1000)
  notes?: string;

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


UpdateAttendanceDeviceDto duplicates all field declarations and validation decorators of CreateAttendanceDeviceDto (only swapping required vs optional). This duplication is error-prone when the schema evolves (fields/decorators can drift between the two classes). Consider deriving it from the create DTO using PartialType(CreateAttendanceDeviceDto) from @nestjs/mapped-types.

import { PartialType } from '@nestjs/mapped-types';

export class UpdateAttendanceDeviceDto extends PartialType(CreateAttendanceDeviceDto) {}

export class UpdateAttendanceDeviceDto {
  @IsOptional()
  @IsString()
  @IsNotEmpty()


IDOR risk: this method (like deleteEnrollment/updateEnrollment/updateExamScore/deleteExamScore/purge* etc.) operates only by `id` without verifying the record belongs to the current student. Note getAttachmentFile does scope by `studentId`, so the API surface is inconsistent. If access is only role-based, any authenticated student can archive/delete other students' records by enumerating ids. Scope the query with the caller's student id (e.g., `{ id, studentId }`) or enforce an explicit ownership check.

  async deleteAttachment(id: number) {
    const entity = await this.attachmentRepo.findOne({ where: { id } });


`fs.writeFileSync` (and `fs.mkdirSync`/`fs.unlinkSync` elsewhere) runs synchronously inside an async request handler and blocks the Node.js event loop, hurting concurrency under load. Use `await fs.promises.writeFile` / `fs.promises.mkdir` instead.


If `attachmentRepo.save()` throws after the file has been written to disk, the file becomes an orphan that is never cleaned up. Wrap the file write + DB save in a try/catch and unlink the file when persistence fails.

    fs.writeFileSync(filePath, file.buffer);

    const entity = this.attachmentRepo.create({


`Date.now()` only has millisecond resolution; two concurrent uploads for the same student in the same millisecond produce the same filename and silently overwrite each other's file (both DB rows then point to the same file). Use `crypto.randomUUID()` or `crypto.randomBytes()` to generate a collision-resistant filename.

const filename = `${studentId}_${Date.now()}${ext}`;


`mimeType` is taken from client-controlled upload metadata (`file.mimetype`) and later used to serve the file. An attacker can upload a file labeled `text/html` containing script, which can lead to stored XSS if served inline from the same origin. Validate actual file content (magic bytes/extension whitelist) and ensure the file is served with `X-Content-Type-Options: nosniff` and `Content-Disposition: attachment`.


`entity.filePath` can be null/empty for legacy or partial records; `resolveAttachmentPath` calls `filePath.replace(...)` which would throw a TypeError (500) instead of a clean 404. Guard for a missing filePath before resolving.

const fullPath = this.resolveAttachmentPath(entity.filePath);


The business status literal `'archived'` is repeated across many methods (enrollment/examScore/learningRecord/attachment). Extract it to a shared constant or enum to avoid typos and drift between soft-delete checks.

if (entity.status === 'archived') throw new BadRequestException('报名记录已归档');


SSRF hardening gap: this private/reserved IP list is missing several ranges commonly used on internal networks, e.g. 198.18.0.0/15 (benchmarking), 192.0.0.0/24, 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved) and 255.255.255.255/32. The IPv6 branch also does not cover the deprecated site-local fec0::/10 (only fe8-feb link-local is matched) nor IPv4-compatible ::/96. An attacker able to point an OPENAI_COMPATIBLE baseUrl at a host in these ranges would bypass the SSRF check. Consider adding the missing ranges and the fec0::/10 prefix.

export function isPrivateHost(hostname: string): boolean {


The DNS lookup phase is not covered by timeoutMs. dns.lookup runs in the libuv threadpool and can block for the OS resolver timeout (typically several seconds with retries), so pinnedGet can hang far beyond the configured timeout; the socket (and its timeout) is only created after resolution completes. Wrap the lookup in a race with a timer (or pass a lookup timeout) so the promise always settles within the caller-visible budget.

    lookup(hostname, { all: true, family: 0 }, (dnsErr, addresses) => {
      if (dnsErr || !addresses || addresses.length === 0) {
        reject(new Error('DNS 解析失败'));
        return;
      }


The Host header omits the port. validateAndNormalizeBaseUrl does not restrict the port, so an admin may configure a base URL with a non-default port (e.g. https://api.openai.com:8443/v1); in that case HTTP/1.1 virtual hosting requires Host: hostname:port, otherwise the server may route the request incorrectly or reject it. Build the Host header conditionally on the effective port, e.g. Host: (port === (isHttps ? 443 : 80)) ? hostname : `${hostname}:${port}` (parsed.hostname already includes IPv6 brackets, so the concatenation is correct).

headers: { ...headers, Host: hostname },


The development fallback encryption key is a hardcoded, publicly known constant (32 bytes of 0xFF). Any ciphertext produced in dev environments can be decrypted by anyone who knows this file, and the same known key is shared across all developers. If dev/test data (which may contain real API keys) is ever leaked or mixed into production backups, the keys are exposed. Prefer generating a random per-boot key in dev (randomBytes(32)) with a clear warning that previously persisted dev ciphertext becomes undecryptable, or fail closed in dev too.

      // 32 hex pairs → 32 bytes
      return Buffer.from('ff'.repeat(32), 'hex');


This error message is misleading: a canonical base64 encoding of a 32-byte key always ends with two '=' padding chars, so an unpadded/under-padded key fails the raw !== canonical comparison and is reported as having 'extra padding', which is the opposite of the actual problem. Rephrase to indicate the key must be the canonical padded base64 form, e.g. 'base64 编码须为标准格式（含正确 padding）'.

      throw new InternalServerErrorException(
        'AI_CONFIG_ENCRYPTION_KEY 格式无效：base64 编码须为标准格式（无多余 padding）',
      );


Duplicate DNS/private-IP logic: validateDnsNotPrivate resolves the hostname and checks isPrivateHost, and pinnedGet re-implements the same resolution + private-IP filtering with a second lookup. Besides the duplication and divergent error messages, this leaves a TOCTOU window in which validation resolves the name to a public IP while the actual request resolves it again (the pin does re-check, but the two lookups may disagree on split-horizon DNS). Consider a single shared helper that resolves and returns the first non-private address, used by both validation and the pinned request, so the checks stay consistent.

  let addresses: { address: string; family: number }[];
  try {
    addresses = await resolveHostnames(hostname);
  } catch {
    throw new BadRequestException('无法解析域名');
  }

  for (const { address } of addresses) {
    if (isPrivateHost(address)) {
      throw new BadRequestException('域名解析到内网地址');
    }
  }


A 3xx response without a Location header (e.g. certain 304 responses) is not treated as a redirect here and will be read and returned as a successful body, even though the documented intent is 'Redirects are forbidden'. Since a redirect is rejected regardless of target, reject on status alone (drop the res.headers.location condition) to keep the policy consistent.

          if (status >= 300 && status < 400 && res.headers.location) {
            res.resume();
            res.destroy();
            return reject(new Error('禁止重定向'));
          }


Validation gap: `normalizeSn`/`deviceName.trim()` run after DTO validation, so a whitespace-only `deviceSn`/`deviceName` (e.g. "   ") passes `@IsNotEmpty` and gets saved as an empty string (also possible in `update` via `patch.deviceSn`/`patch.deviceName`). An empty SN then collides with the unique index on later inserts, and an empty device name is persisted. Trim and re-validate (reject empty) before saving.

    const deviceSn = this.normalizeSn(dto.deviceSn);
    if (!deviceSn) throw new BadRequestException('SN 不能为空');
    await this.assertClassroomExists(dto.classroomId);

    const deviceSn = this.normalizeSn(dto.deviceSn);
    await this.assertClassroomExists(dto.classroomId);


TOCTOU race on SN uniqueness: the existence check and the subsequent insert/update are not atomic. The entity has a unique index on `deviceSn`, so two concurrent requests with the same SN can both pass `findOne` and one will fail with an unhandled DB unique-constraint error (500) instead of the intended 400. Same applies to the SN check in `update`. Consider wrapping `repo.save`/`repo.update` and translating `QueryFailedError` (duplicate key) into `BadRequestException`, or rely on the unique index as the source of truth.

    const exists = await this.repo.findOne({ where: { deviceSn } });
    if (exists) throw new BadRequestException(`SN ${deviceSn} 已绑定教室`);


Loose inequality (`!= null`) is used for the null checks; per project rules strict equality (`===`/`!==`) is required. Use `dto.classroomId !== undefined && dto.classroomId !== null` (or an explicit `isNil` helper).

    if (dto.classroomId !== undefined && dto.classroomId !== null) await this.assertClassroomExists(dto.classroomId);
    if (dto.deviceSn !== undefined && dto.deviceSn !== null) {

    if (dto.classroomId != null) await this.assertClassroomExists(dto.classroomId);
    if (dto.deviceSn != null) {


Duplicate logic: the SN normalization + uniqueness check is implemented twice (create and update) with only the self-exclusion differing. Extract a shared helper such as `private async assertSnAvailable(sn: string, excludeId?: number)` and reuse it in both places to avoid divergence.

    if (dto.deviceSn != null) {
      const deviceSn = this.normalizeSn(dto.deviceSn);
      const exists = await this.repo.findOne({ where: { deviceSn } });
      if (exists && exists.id !== id) throw new BadRequestException(`SN ${deviceSn} 已绑定教室`);
      patch.deviceSn = deviceSn;
    }


Off-by-one weekday bug: `new Date(`${date}T00:00:00+08:00`)` converts China-midnight to an instant whose UTC time is 8 hours earlier (the previous calendar day), and `.getUTCDay()` then returns the weekday of that previous day. E.g. `2026-08-10` (Monday in China) becomes `2026-08-09T16:00Z` (Sunday) and this method returns `7` instead of `1`. As a result `getScheduleOptionsForAttendance` always queries the wrong `cs.weekDay` and returns schedules for the incorrect weekday. Use dayjs (with the same +08 offset used elsewhere in this file) so `.day()` is evaluated in the China timezone.

  private getWeekDayForDate(date: string): number {
    const day = dayjs(`${date}T00:00:00+08:00`).day();
    return day === 0 ? 7 : day;
  }

  private getWeekDayForDate(date: string): number {
    const day = new Date(`${date}T00:00:00+08:00`).getUTCDay();
    return day === 0 ? 7 : day;
  }


China timezone offset (+8) is hardcoded in three places with two different mechanisms (`dayjs().utcOffset(8)` here/in `buildCalendar` and the `+08:00` literal in `getWeekDayForDate`). This is business-specific magic that is easy to get out of sync (and is exactly what caused the off-by-one in `getWeekDayForDate`). Extract a named constant (e.g. `CHINA_TZ_OFFSET = 8` / `CHINA_TZ_SUFFIX = '+08:00'`) and centralize weekday/date helpers.


`weekStart` is validated with `@IsDateString()`, which also accepts ISO timestamps with a time/offset component (e.g. `2026-08-09T00:00:00.000Z`), not just `YYYY-MM-DD`. Passing such a value here produces a `Between(weekStart, endStr)` where the lower bound mixes a timestamp with a date-only upper bound and the `end` computed via `new Date(weekStart)` + `setDate` relies on server-local `getDate()`, so `endStr` can drift. Normalize `weekStart` to a date-only string (and construct `start` explicitly in the +08 zone) before computing the range to keep the query deterministic.

    const weekStartDate = weekStart.slice(0, 10);
    const start = new Date(`${weekStartDate}T00:00:00+08:00`);
    const end = new Date(start);
    end.setDate(start.getDate() + 6);
    const endStr = dayjs(end).utcOffset(8).format('YYYY-MM-DD');

    const start = new Date(weekStart);
    const end = new Date(start);
    end.setDate(start.getDate() + 6);
    const endStr = dayjs(end).utcOffset(8).format('YYYY-MM-DD');


Nested sequential await loop: for every date × every user the code issues an API call and DB writes one after another, so a lesson spanning many days with many students results in N×M sequential network round-trips (plus resolveStudentName does 2 extra DB queries per new record). These per-user/per-date operations are independent and should run in parallel — e.g. collect per-date Promise.all batches with a concurrency limit — to avoid multi-minute syncs.

      for (const userId of userIds) {
        try {
          const leaves = await this.dingTalkService.fetchDailyLeaveStatus(userId, date);


The doc comment promises "单条失败只记录错误、不中断整批" (a single failed record only logs an error without interrupting the batch), but the try/catch wraps the whole user×date iteration: if one leave's upsert throws, all remaining leaves for that user/date are silently skipped and only one generic error is recorded. To honor the documented intent, wrap each `upsertLeave` call in its own try/catch so a single bad record doesn't cause other leave data to be lost from settlement.

          for (const leave of leaves) {
            await this.upsertLeave(leave);
            synced++;
          }


`findOne` then `create`/`save` is a check-then-act pattern: `dingId` has a unique constraint, so if this service runs concurrently (e.g. two lesson syncs overlapping the same date range) both requests can pass the `findOne` miss and one insert will throw a duplicate-key error, failing the whole user×date batch. Prefer an atomic upsert (e.g. `insert` with `orUpdate`/`orIgnore` keyed on `dingId`, or catch the unique-violation and re-query).

    const existing = await this.dingLeaveRawRepo.findOne({
      where: { dingId: result.procInstId },
    });


On the update path `userName` is never refreshed: if a record was synced before a StudentDingMapping existed (leaving userName as ''), subsequent re-syncs of the same approval never backfill the name even though the mapping now exists. Consider resolving/updating `userName` here as well (or backfilling in autoMatchLeaveRecords).

      Object.assign(existing, {
        dingUserId: result.userId,
        workDate: result.workDate,


The literal status strings 'unmatched' / 'matched' are hardcoded in multiple places (create default, autoMatch filter/assignment). Extract them into a shared enum/const (e.g. MatchStatus) so typos or future status values can't silently diverge between the write path and the query path.


N+1 query / redundant computation inside the nested loop: `mapScheduleTimeToSession` calls `ensureAttendancePeriodConfigs()` which performs DB queries (count + find) on every call, and it is awaited once per schedule per day (e.g. 4 schedules × 7 days = 28+ DB round trips). Also `classStudents.filter(isClassStudentActiveOnDate)` is recomputed for every schedule even though it only depends on `dateStr`. Pre-resolve the session for each schedule once (e.g. `Map<scheduleId, session>` built with `Promise.all`) before the date loop, and compute `classStudentsForDate` once per date outside the schedule loop.

const session = scheduleSessions.get(sched.id)!;

        const session = await this.mapScheduleTimeToSession(sched.startTime);
        const classStudentsForDate = classStudents.filter((cs) =>
          isClassStudentActiveOnDate(cs, dateStr),
        );


Sequential awaited DB calls in a loop: `mapScheduleTimeToSession` hits the DB (via `ensureAttendancePeriodConfigs`) for every schedule, and each iteration awaits the previous one. Resolve the session for all schedules in parallel (single call to fetch the enabled periods, then match in memory), e.g. `await Promise.all(schedules.map(...))` or fetch periods once before the loop.

if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) {


Day-of-week/timezone inconsistency: `new Date(dateFrom)` with a date-only string is parsed as UTC midnight, and `d.getDay()` is evaluated in the server's local timezone, while `dateStr` is formatted with a fixed UTC+8 offset. On servers running in a timezone west of UTC (or any TZ where the UTC date differs from the +8 date), `weekDay` will not correspond to `dateStr`, so records can be generated for the wrong weekday. Derive both from the same UTC+8 `dayjs` instance.

      const d8 = dayjs(d).utcOffset(8);
      const dateStr = d8.format('YYYY-MM-DD');
      const weekDay = d8.day() === 0 ? 7 : d8.day();

      const dateStr = dayjs(d).utcOffset(8).format('YYYY-MM-DD');
      const weekDay = d.getDay() === 0 ? 7 : d.getDay();


`clear()` followed by `save()` is not transactional (also in `resetAttendancePeriodConfigs`). If `save` fails or a concurrent request reads/configures periods in between, the existing period configs are lost and the table is left empty or inconsistent. Wrap both operations in `this.dataSource.transaction(...)` using the transaction's repository.

    await this.dataSource.transaction(async (manager) => {
      const repo = manager.getRepository(AttendancePeriodConfig);
      await repo.clear();
      await repo.save(normalized.map((period) => repo.create(period)));
    });

    await this.attendancePeriodConfigRepo.clear();
    await this.attendancePeriodConfigRepo.save(
      normalized.map((period) => this.attendancePeriodConfigRepo.create(period)),
    );


Dead code: `mapLessonScheduleTimeToSession` is a private method that is never called anywhere in this file, and it references a `night_check` period key that does not exist in `defaultAttendancePeriods` or in `saveAttendancePeriodConfigs` handling. Remove it to avoid confusion with the actually-used `mapScheduleTimeToSession`.

  private mapLessonScheduleTimeToSession(startTime: string): string {
    const hour = parseInt(startTime.slice(0, 2), 10);


Duplicate logic / shadowed import: the private `toMinutes` method duplicates the `toMinutes` imported from './attendance-time'. Because class methods shadow module-level imports, every unqualified `toMinutes(...)` call in this file resolves to the private copy, leaving the imported one unused (dead import). Keep only one implementation to avoid two definitions drifting apart.

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


Likely copy-paste typo in the default config: the `afternoon` (14:00–17:00) period is labeled '晚课' (evening class), which overlaps semantically with `evening_study` labeled '晚自习'. It is probably meant to be '午课'/'下午课'.

{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 },


Missing time-format validation: if `startTime`/`endTime` are not `HH:mm`, `toMinutes` returns NaN and all comparisons (`NaN <= NaN`, `NaN < NaN`) evaluate to false, so invalid times silently pass validation and get persisted. Validate the `HH:mm` format before comparing.

      if (!/^\d{2}:\d{2}$/.test(period.startTime) || !/^\d{2}:\d{2}$/.test(period.endTime)) {
        throw new BadRequestException(`${label} 的时间格式必须为 HH:mm`);
      }
      if (toMinutes(period.endTime) <= toMinutes(period.startTime)) {
        throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`);
      }


The safety timeout can corrupt the single-import guarantee. If the timer fires while the original import is still genuinely running (which is the only case where it can fire, since `finally` always clears it on the normal path), it sets `isRunning = false` but does nothing to stop the hung import. A second import can then start concurrently, and both share the same `progressSubject` / `importingUserId`, so: (1) the old import's later `emit` calls get tagged with the new user's ID, breaking SSE scoping; (2) when the old import finally exits, its `finally` block resets `isRunning = false` and `importingUserId = undefined` while the new import is still running, allowing a third import to start. Consider using a per-run token/generation counter so only the matching run resets the flags, and refuse starting a new import until the previous run has truly finished.

    const safetyTimer = setTimeout(() => {
      if (this.isRunning) {
        this.logger.error('Import safety timeout triggered — force-resetting isRunning');
        this.isRunning = false;
      }
    }, SAFETY_TIMEOUT_MS);


Batch save failures are collected into `errors` and the loop continues, but the method still returns `success: true`. If every batch fails (e.g., unique-constraint violation or a transient DB error), callers that rely on the `success` flag will treat a fully failed import as successful. Return `success: false` (or a partial-success indicator) when `errors.length > 0`, and reflect the errors in the 'complete' progress event as well.

return { success: true, imported, skipped, matched, errors, duration };


For every record that lacks `userName`, `resolveStudentName` runs two sequential DB queries (mapping lookup + student lookup). With thousands of imported records this is an N+1 pattern (up to 2×N queries, executed in `Promise.all` batches of 100). Batch-load the `StudentDingMapping`/`Student` rows for the whole page with `In(...)` and resolve names in memory instead.

entity.userName = r.userName || await this.resolveStudentName(r.userId);


`In(dingIds)` builds a single `WHERE dingId IN (...)` with one bind variable per record. For large imports the clause can exceed the database's max bind-variable/expression limit (e.g., ~999 for SQLite, 65535 for MySQL/PostgreSQL), causing the whole import to fail with a generic error. Chunk the id list (e.g., 500–1000 ids per query) and merge the results.

    const existing = await this.dingRawRepo.find({
      where: { dingId: In(dingIds) },
    });


Deduplication only compares against existing DB records, not against other records in the same fetched result. If the same `checkId` appears twice in `rawResults` (or is returned by overlapping API responses), both entries are treated as new; the second insert then violates the unique constraint and is reported as a batch error while the import still claims `success: true`. Deduplicate `rawResults` in memory by `checkId` before the DB lookup/filter.

const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));


`progressSubject` is never completed or replaced, and there is no subscription cleanup after an import finishes. Long-lived SSE subscriptions that don't unsubscribe will accumulate over repeated imports. Consider completing the Subject (or replacing it per import run) once the pipeline finishes/errors.

private progressSubject = new Subject<ImportProgressEvent>();


Uses non-strict equality `!=` (twice here), which violates the project's strict-equality rule. Note that `record.classId` is typed `number` (non-nullable) in the entity, so this filter is only a runtime-null guard; if the null/undefined check is intended, express it strictly as `!== null && !== undefined` (or use a nullable field type) instead of `!=`.

      ...records.map((record) => record.classId).filter((id): id is number => id !== null && id !== undefined),
      ...(classroomId !== null && classroomId !== undefined ? [classroomId] : []),

      ...records.map((record) => record.classId).filter((id): id is number => id != null),
      ...(classroomId != null ? [classroomId] : []),


Hardcoded business status literal `'active'`. The entity already exports the `AttendanceDeviceStatus` enum (`ACTIVE = 'active'`), so use the enum constant to avoid string-literal drift and keep status values centralized.

where: { classroomId: In(classroomIds), status: AttendanceDeviceStatus.ACTIVE },

where: { classroomId: In(classroomIds), status: 'active' },


These two repository queries are independent (SN lookup vs. classroom lookup) but are awaited sequentially, adding two round-trip latencies. They can be executed concurrently with `Promise.all`, which is preferable for independent async operations.

    const devices = await attendanceDeviceRepo.find({
      where: { deviceSn: In(sns) },


Missing class-scope authorization for match/auto-match. Unlike `getDingRaw` (assertClassAccess) and `importFromDingTalk` (getTeacherClassDingUserIds), neither `matchDingRecord` nor `autoMatch` verifies that the raw record/student belongs to the requester's accessible classes. The underlying `AttendanceRecordMutationService.matchDingRecord` simply loads the record by id and saves the match, and `autoMatchDingRecords` iterates ALL unmatched records globally. A user holding only `attendance:edit` (e.g., a teacher) can match records belonging to other classes by guessing IDs and trigger a global auto-match. Suggest resolving the raw record's class and calling `assertClassAccess` before matching, and scoping `autoMatch` to the user's accessible classes (or requiring `canManageAllAttendance`).

  async matchDingRecord(
    @Param('id', ParseIntPipe) id: number,
    @Body() dto: MatchDingRecordDto,
    @Request() req: { user: RequestUser },
  ) {
    const result = await this.service.matchDingRecord(id, dto);


SSE stream can hang indefinitely. `importService.progress$` is backed by a plain RxJS `Subject` (see attendance-import.service.ts), so any event emitted before the subscriber connects is lost. If the client connects when no import is running — or after the import already finished — the observable never emits and never reaches the `complete`/`error` phase, so `subscriber.complete()` is never called and the connection stays open until the client disconnects. There is also no heartbeat/keep-alive event, which can cause idle connection drops behind proxies. Consider exposing a replay of the last event (e.g., BehaviorSubject/ReplaySubject(1)), emitting an immediate "no active import" snapshot, and adding a keep-alive heartbeat plus a timeout that terminates stale connections.

    return new Observable<SseEvent>((subscriber) => {
      const subscription = this.importService.progress$
        .pipe(filter((event) => event.userId === userId))
        .subscribe({


Inconsistent error handling in the import endpoint. When another import is already running, `AttendanceImportService.importFromDingTalk` throws a plain `Error('An import is already in progress')` which surfaces to the client as an HTTP 500; and when the pipeline itself fails, the service returns `{ success: false, errors }` but this controller still replies HTTP 200 and writes a normal audit entry that omits the error details. Consider catching the concurrency exception and mapping it to a 409 Conflict, and when `result.success === false` include `result.errors` in the audit log detail / response so failures are observable.

    const result = await this.importService.importFromDingTalk({
      startDate,
      endDate,
      userIds,
      autoMatch: true,
      userId: req.user.id,
    });


Performance: this query loads the student's ENTIRE leave history (every leave row ever synced) and then filters/sorts in memory. It runs once per student during finalize (from buildLessonRecord inside Promise.all over classStudents, inside a transaction), so a class of N students triggers N full-history queries while the transaction is held open. Push the time-window filter down to SQL — e.g. filter on workDate between window.dateFrom/window.dateTo (uses the existing workDate index) or startTime <= window.end && endTime >= window.start — and select only the needed columns instead of materializing all rows.

  const leaves = await dingLeaveRawRepo.find({
    where: {
      matchedStudentId: studentId,
      startTime: LessThanOrEqual(new Date(window.end)),
      endTime: MoreThanOrEqual(new Date(window.start)),
    },
  });

  const leaves = await dingLeaveRawRepo.find({
    where: { matchedStudentId: studentId },
  });


The function is named findApprovedLeaveForStudent and its doc comment promises only approved leave ('钉钉已审批通过的请假'), but the filter never checks that approvedAt is non-null; it only sorts by it. The DingTalk sync currently stores only finished approvals, but the entity column is nullable and this contract is not enforced at read time — any pending/rejected record present in the table would be treated as a valid leave and incorrectly change the student's status from absent to leave. Add a defensive approvedAt check.

  const overlapping = leaves.filter(
    (leave) =>
      leave.approvedAt &&
      leave.startTime &&
      leave.endTime &&
      leave.startTime.getTime() <= window.end &&
      leave.endTime.getTime() >= window.start,
  );

  const overlapping = leaves.filter(
    (leave) =>
      leave.startTime &&
      leave.endTime &&
      leave.startTime.getTime() <= window.end &&
      leave.endTime.getTime() >= window.start,
  );


Nested ternary buried inside a `||` expression violates the project rule that nested ternary expressions are not allowed, and the fallback chain (metadataRecord?.punchSource -> attendanceType for non-standard types -> primary.punchSource) is hard to follow. Extract this into a small helper with if/else statements and add a comment explaining the fallback intent.

  const source =
    metadataRecord?.punchSource ||
    (metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
      ? metadataRecord.attendanceType
      : primary.record.punchSource);


Business values such as DingTalk's attendance types 'OnDuty'/'OffDuty' (and the default advance of 30 minutes in getLessonAttendanceWindow) are hardcoded and duplicated in getLessonPunchMetadata. Extract them into named constants so the semantics and any API-driven changes are centralized.

      !!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
      !['OnDuty', 'OffDuty'].includes(record.attendanceType),


The fixed timezone '+08:00' is hardcoded in multiple date constructions (here and in getLessonPunchMetadata), and the same `new Date(`${lessonDate}T${time}:00+08:00`)` pattern is duplicated across functions. If the business timezone ever changes or the server runs outside Asia/Shanghai, these silently diverge from `shiftDate`/`dayjs.utc` usage. Centralize into a single timezone constant/helper to keep them consistent.

const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime();


Performance: this loop issues one `save()` per unmatched record sequentially (N+1). These updates are independent, so they should be batched — either collect all matches and run them with `Promise.all`, or use a single bulk `UPDATE ... WHERE id IN (...)` via `createQueryBuilder().update()`. Sequential awaits will be slow when the unmatched set is large.

      await Promise.all(
        unmatched.map((record) => {
          const studentId = dingToStudentId.get(record.dingUserId);
          if (studentId == null) return Promise.resolve();
          return this.dingRawRepo.update(record.id, {
            matchedStudentId: studentId,
            matchStatus: 'matched',
          });
        }),
      );

      record.matchedStudentId = studentId;
      record.matchStatus = 'matched';
      await this.dingRawRepo.save(record);
      matched++;


Atomicity: `autoMatchDingRecords` mutates records one by one without a transaction. If any `save` fails mid-loop, the exception propagates and leaves a partial state (some records 'matched', the rest still 'unmatched') with no rollback. Wrap the whole matching loop in `this.dataSource.transaction(...)` so the operation is all-or-nothing.

    let matched = 0;
    for (const record of unmatched) {
      const studentId = dingToStudentId.get(record.dingUserId);
      if (studentId == null) continue;


Maintainability: the mutex + transaction + session/completed re-check + fresh-record re-fetch logic in `update()` and `remove()` is duplicated almost verbatim. Extract a private helper (e.g. `withSessionLock(id, fn)`) that performs the mutex acquisition, transaction, session validation and fresh-record lookup, and have both `update()` and `remove()` delegate to it so the two paths cannot drift apart.

    return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
      this.dataSource.transaction(async (manager) => {
        const recordRepo = manager.getRepository(AttendanceRecord);
        const sessionRepo = manager.getRepository(AttendanceSession);

        // Re-check session status inside the transaction while holding the lock
        const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
        if (!session || session.status === 'completed') {
          throw new BadRequestException('已完成考勤的记录不允许修改或删除');
        }

        const freshRecord = await recordRepo.findOne({ where: { id } });
        if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);


Data integrity: `matchDingRecord` accepts any `studentId` without verifying the student exists. An invalid id will fail at the DB with a FK constraint error (surfacing as a 500) rather than a clean 4xx, and a valid-but-different id silently overwrites an already-matched record. Validate the student's existence (throw NotFoundException/BadRequestException) before assigning, and consider rejecting overwrites of records already in 'matched' state unless explicitly intended.

    record.matchedStudentId = dto.studentId;
    record.matchStatus = 'matched';
    return this.dingRawRepo.save(record);


The queue has no timeout or abort mechanism. If the wrapped `fn` (e.g. the DB transaction in attendance-record-mutation.service.ts) never settles — DB stall, deadlock in the connection pool — every subsequent `runExclusive` call for that sessionId will wait forever on `await tail`, and each call appends a new tail promise to `queueTails` that is never removed (the `finally` cleanup only runs when the last queued op completes). Over time this permanently blocks the session and grows the map without bound. Consider adding a per-call timeout (settle with an error after a configurable duration) and/or an `abort(sessionId)`/queue-clear API so a hung operation can be evicted instead of wedging the session forever.

    this.queueTails.set(sessionId, newTail);
    await tail;


This mutex is non-reentrant: if `fn` itself calls `runExclusive` with the same `sessionId` (directly or transitively through a nested service call), the inner call awaits the outer call's `newTail`, which only resolves after the outer `fn` completes — a guaranteed deadlock with no detection. The current call sites don't nest, but this is a latent hazard worth guarding against (e.g. detect same-session re-entry and throw) or at least documenting on the class.

async runExclusive<T>(sessionId: number, fn: () => Promise<T>): Promise<T> {


Accessibility filter is only applied when `query.classId` is NOT provided. If a caller passes both `classId` and `accessibleClassIds` where the classId is outside the user's accessible set, the classId filter wins and the accessibility check is silently skipped — a potential authorization bypass. Either have the controller validate classId against accessibleClassIds, or intersect here, e.g.: if `accessibleClassIds && !accessibleClassIds.includes(query.classId)` return zeroed summary.

    if (accessibleClassIds && query.classId && !accessibleClassIds.includes(query.classId)) {
      return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 };
    }
    if (!query.classId && accessibleClassIds) {


Same authorization concern as in `getSummary`: when `query.classId` is supplied, `accessibleClassIds` is ignored, so a caller can query attendance records of any class by passing an arbitrary classId. Ensure the controller verifies `classId` membership in `accessibleClassIds`, or enforce it here.

    if (query.classId) {
      if (accessibleClassIds && !accessibleClassIds.includes(query.classId)) {
        return { list: [], total: 0, page, pageSize };
      }
      qb.andWhere('ar.classId = :classId', { classId: query.classId });
    } else if (accessibleClassIds) {


When `query.classId` is provided, `accessibleClassIds` is completely ignored, so a user could query DingTalk raw records for a class they have no access to by simply passing the classId. Apply an intersection check so classId is only honored when it falls within the user's accessible set.

    const scopedClassIds = query.classId
      ? (accessibleClassIds && !accessibleClassIds.includes(query.classId) ? [] : [query.classId])
      : accessibleClassIds;

const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds;


`getMany()` materializes every matching row and the aggregate counts are computed in JS. For large date ranges / many classes this loads all records into memory. Prefer SQL-side aggregation, e.g. COUNT + SUM(CASE WHEN status='present' THEN 1 ELSE 0 END) via `getRawOne()`/`getCount()`, so only one aggregate row is returned.


When `accessibleClassIds` is provided, the returned classes are derived purely from the accessible id list, regardless of whether any attendance records exist — this contradicts the method's stated purpose ("Get distinct classes with attendance records"). If only classes that actually have attendance data should be listed, intersect accessibleClassIds with the distinct classIds present in the attendance table.

    const rows: Array<{ classId: string | number }> = accessibleClassIds
      ? accessibleClassIds.map((classId) => ({ classId }))
      : await qb.orderBy('ar.classId', 'ASC').getRawMany();


Pagination and date/status filter-building logic is duplicated between `findAll` and `getDingRaw` (identical page/pageSize clamping and dateFrom/dateTo conditions). Consider extracting a shared helper to keep filter semantics consistent and avoid drift.

    const page = Math.max(1, Math.floor(Number(query.page) || 1));
    const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20)));


`toMinutes` does not validate its input: if `time` is not exactly `HH:MM` (e.g. missing colon, non-numeric segment, or just "8"), `hour * 60 + minute` evaluates to `NaN` and silently propagates to callers (e.g. overnight/overlap comparisons in attendance-dingtalk and attendance-generation), potentially breaking attendance logic without any error signal. Suggest validating and throwing a descriptive error for malformed input.

export function toMinutes(time: string): number {
  const parts = time.split(':').map(Number);
  if (parts.length !== 2 || parts.some(Number.isNaN)) {
    throw new Error(`Invalid time format: ${time}`);
  }
  const [hour, minute] = parts;
  return hour * 60 + minute;
}

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


When `startTime` is empty or malformed, `parseInt('', 10)` returns `NaN`, all range checks are false, and the function silently returns `'night_check'` — an incorrect session classification that masks data errors. Validate the input (throw on `Number.isNaN(hour)`) instead of falling through to a misleading default, or make the default an explicit 'unknown' session.

  if (Number.isNaN(hour)) {
    throw new Error(`Invalid startTime: ${startTime}`);
  }
  if (hour < 20) return 'evening_study';
  return 'night_check';
}


Timezone handling is inconsistent within this module: `getCourseClock` derives date/minutes from the process's local timezone (`dayjs(date)`), while `shiftDate` forces UTC. These only agree because the deployment assumes Asia/Shanghai (per the comment in common/dayjs.ts); if the server ever runs with a different `TZ`, `getCourseClock` will return a different calendar date than `shiftDate`/`lessonDate`, causing off-by-one-day attendance errors. Consider making both UTC-based or passing an explicit timezone to keep date-only arithmetic consistent.

export function getCourseClock(date: Date): { date: string; minutes: number } {
  const d = dayjs.utc(date);

export function getCourseClock(date: Date): { date: string; minutes: number } {
  const d = dayjs(date);


The session names (`'morning_reading'`, `'morning'`, `'afternoon'`, `'evening_study'`, `'night_check'`) are business keys hardcoded in a stringly-typed way. They are used for lookups elsewhere and would benefit from being shared constants (or a union type) to avoid typos and to keep the hour boundaries documented.

export function mapLessonScheduleTimeToSession(startTime: string): string {
  const hour = parseInt(startTime.slice(0, 2), 10);
  if (hour < 8) return 'morning_reading';


The "consecutive" absence/late detection does not actually check that dates are consecutive calendar days. The query only returns records with status IN ('absent','late'), so any intervening present/leave/pending record is invisible — a student absent Mon, present Tue, absent Wed will be counted as count=2 "consecutive" absences. Additionally, multiple sessions on the same day (morning/afternoon) are counted as separate days, and the lastDate update (`r.attendanceDate > current.lastDate`) never fires because the list is already sorted by date DESC. To correctly detect consecutive absences, group records per student per day (dedupe by date) and verify date difference === 1 day between consecutive entries.

      if (current && current.studentId === r.studentId && current.type === status) {
        current.count++;
        if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;


Statuses other than the four handled here (e.g., 'pending', which is a valid status elsewhere in this module — see attendance-query.service.ts getSummary which counts it) are silently dropped: they contribute to neither `total` nor the rates, so the report statistics are skewed whenever pending/unhandled records exist. Add a `pending` field (or otherwise include unknown statuses) in the aggregation and in `total`.

      if (row.status === 'present') entry.present += count;
      else if (row.status === 'absent') entry.absent += count;
      else if (row.status === 'late') entry.late += count;
      else if (row.status === 'leave') entry.leave += count;


Timezone handling mixes server-local time arithmetic with a fixed UTC+8 formatting offset: `new Date()`/`setDate` operate in the server's local timezone while `dayjs(cutoff).utcOffset(8)` displays the result in UTC+8. If the server's local timezone is not UTC+8 (and its calendar date differs from Beijing's at the moment of execution), the cutoff date can be off by one day. Since the project standardizes on UTC+8 (see common/dayjs.ts), compute the cutoff directly from a UTC+8 now, e.g. `dayjs().utcOffset(8).subtract(days, 'day').format('YYYY-MM-DD')`.

    const cutoff = new Date();
    cutoff.setDate(cutoff.getDate() - days);
    const cutoffStr = dayjs(cutoff).utcOffset(8).format('YYYY-MM-DD');


The classId/accessibleClassIds access-control filter and the dateFrom/dateTo filter blocks are duplicated (with minor variations) across findAllForExport, getReport, and getAlerts. Consider extracting a private helper that builds a base QueryBuilder with these filters applied, to avoid divergence between the three methods.

    if (query.classId) {
      qb.andWhere('ar.classId = :classId', { classId: query.classId });
    } else if (accessibleClassIds) {
      if (accessibleClassIds.length === 0) return [];
      qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
    }


Uses loose equality `== null` / `!= null`. Per project rules, strict equality is required. Since `record.classId` is an optional `number`, check explicitly for null/undefined (e.g. `record.classId == null` should be `record.classId === null || record.classId === undefined`, or use `record.classId != null` → strict equivalent). The same pattern also appears later in `update`/`remove` (`existing.classId == null`).

    if (!canManageAll && dto.records.some((record) => record.classId === null || record.classId === undefined)) {
      throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');
    }

    if (!canManageAll && dto.records.some((record) => record.classId == null)) {
      throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');
    }


When `importFromDingTalk` returns `success: false` with an empty `errors` array, this branch pushes nothing and `continue`s, so the failure is silently swallowed: the audit log reports status `success` and the response `errors` is empty even though nothing was refreshed. Make the failure branch always surface an error message.

        if (!importResult.success) {
          errors.push(
            importResult.errors.length > 0
              ? importResult.errors.join('；')
              : `排课 ${schedule.id} 刷新失败（无详细错误）`,
          );
          continue;
        }
        if (importResult.errors.length > 0) {
          errors.push(...importResult.errors);
          continue;
        }

        if (!importResult.success || importResult.errors.length > 0) {
          errors.push(...importResult.errors);
          continue;
        }


`encodeURIComponent()` output is placed in the plain `filename` parameter, which is NOT percent-decoded by most browsers — exported files will be saved with a garbled/percent-encoded name (e.g. `%E8%80%83%E5%8B%A4...xlsx`). Use an ASCII fallback filename plus the RFC 5987 `filename*=UTF-8''...` parameter for the Chinese name.

    res.setHeader(
      'Content-Disposition',
      `attachment; filename=attendance-report.xlsx; filename*=UTF-8''${encodeURIComponent(`考勤统计报表-${dateRange}`)}.xlsx`,
    );

    res.setHeader(
      'Content-Disposition',
      `attachment; filename=${encodeURIComponent(`考勤统计报表-${dateRange}`)}.xlsx`,
    );