Nested ternary expression (`type === 'students' ? 'profile' : type === 'rooms' ? 'room' : 'checkin'`) violates the project rule that nested ternaries are not allowed. Extract to a small mapping function or if/else chain for readability.

        sectionTypes.map((type) => {
          if (type === 'students') return 'profile';
          if (type === 'rooms') return 'room';
          return 'checkin';
        }),

        sectionTypes.map((type) =>
          type === 'students'
            ? 'profile'
            : type === 'rooms'
              ? 'room'
              : 'checkin',
        ),


`onReady` is invoked explicitly here and then passed to `runGenerationAndRelease`, which calls `executeGeneration` — and `executeGeneration` calls `onReady()` again at its start. The controller's `onReady` is guarded by `res.headersSent`, so the second call is a no-op today, but this double-invocation is fragile (any future change that removes the guard will fire it twice) and inconsistent with `submitForm`, which only passes `onReady` through. Keep only one path for invoking `onReady`.

    const serialized = context.reviewService.serialize(updatedReview);
    onReady();
    emit('ui.artifact', {


Non-atomic confirmation flow: `submitAll` (the actual data import) runs outside the `persistExchange` transaction, and `recordSubmission` persists a `created` record up front that is never updated to a failed/completed state on any later failure. If `submitAll` succeeds but `persistExchange` (or `markReviewSubmittedOnMessage`) throws, the review data is already imported while the conversation message is missing, and any retry with the same `clientRequestId` is rejected as a duplicate ("该导入预览已确认过"), wedging the artifact. `submitForm` has the same pattern (`markSubmitted`/`markFormSubmittedOnMessage` run after the transaction commits). Consider wrapping the artifact state change + exchange persistence in one transaction and updating the submission record's status (or deleting it) on failure so a partial failure can be retried consistently.

    const { review: updatedReview, result } = await context.reviewService.submitAll(
      review.id,
      user.id,
    );


`submitForm` and `submitReview` share a large amount of duplicated logic: fetching the owned pending artifact, `requireOwnedConversation`, computing `effectiveSkillKey`, `assertSkillAvailable`, `recordSubmission` + duplicate-check, `acquireConversation` + try/finally cleanup, `persistExchange` with the `DEFAULT_TITLE` title update, `emit('ui.artifact')`, and the final `runGenerationAndRelease`. This duplication makes it easy for fixes to one flow to miss the other (e.g., the inconsistent `onReady` handling). Consider extracting a shared helper (e.g., a generic `runSubmission` that takes the artifact-specific steps as callbacks).


This function performs DB I/O (findOne + save) without any error handling, yet it is awaited by callers after the submission transaction has already been committed (e.g., ai-chat.submissions.flow.ts:74). If the save throws (transient DB error, constraint conflict) the whole request fails for the user even though the form submission already succeeded. Also, when the assistant message is not found the function silently no-ops and the stored message metadata is never marked 'submitted', leaving it inconsistent with the emitted ui.artifact. Since this is best-effort bookkeeping, wrap the read/write in try/catch with logging (or return a boolean indicating whether the mark was applied) so failures here cannot break the completed submission flow.

export async function markFormSubmittedOnMessage(
  context: AiChatServiceContext,
  assistantMessageId: number,
  conversationId: number,
): Promise<void> {
  const assistant = await context.messages.findOne({
    where: { id: assistantMessageId, conversationId },
  });


Same robustness issue as markFormSubmittedOnMessage: this function is awaited (ai-chat.submissions.flow.ts:204, ai-chat.review-confirm.ts:27) after the review/import side effects have already been persisted, and an unhandled save error here will surface a failure response to the user despite the operation having succeeded. Additionally, in the review-provided branch the whole a2uiReview object is replaced by serialize(review), which will also drop any previously stored metadata keys on that object that are not part of the serialized shape. Consider guarding the read-modify-write with try/catch + logging.

export async function markReviewSubmittedOnMessage(
  context: AiChatServiceContext,
  assistantMessageId: number,
  conversationId: number,
  review?: AiReview,
): Promise<void> {
  const assistant = await context.messages.findOne({
    where: { id: assistantMessageId, conversationId },
  });


The truncation limit '32 * 1024' is a hard-coded magic number and String.prototype.slice operates on UTF-16 code units, so truncation can split a surrogate pair (e.g., emoji in submitted values) and can cut JSON mid-structure, producing a malformed snippet in the model prompt. Meanwhile fieldErrors is serialized with no length cap at all, so an oversized fieldErrors array can push the prompt over the context limit. Suggest extracting a named constant (e.g., MAX_JSON_SNIPPET) and applying the same truncation/length guard to fieldErrors; wrapping its JSON.stringify in try/catch would also make it consistent with the guarded submit.values serialization.

return `【表单提交：${submit.title}】${submissionId}\n提交值（JSON）：${json.slice(0, 32 * 1024)}${fieldErrors}\n用户已在表单中确认，你可以执行允许的写操作工具。`;


The transaction commits destructive changes (editing the target user message and deleting ALL later messages) before generation runs. The subsequent `context.conversations.update` and `removeOrphans` calls run outside the transaction: if either throws, or the process crashes after commit, the conversation is left with its history irrecoverably truncated plus a dangling 'pending' assistant message, and the client receives an error with no generation ever started. Consider moving the conversation title/lastMessageAt update into the same transaction (using `manager.update`), and making the orphan cleanup best-effort (catch/log) so a file-cleanup failure cannot block generation.

    await context.conversations.update(
      { id: conversationId, userId: user.id },
      {
        lastMessageAt: now,
        ...(conversation.title === oldTitleHint ? { title: context.titleFromMessage(content) } : {}),
      },
    );
    await context.attachmentService.removeOrphans(user.id, orphanAttachmentIds);


Messages whose `status !== 'completed'` (e.g., pending/cancelled assistant rows left behind by interrupted generations) are skipped in the loop, yet they still occupy one slot of the `take: MAX_HISTORY_MESSAGES + 1` fetch window. In a long conversation with several interrupted regenerations, older completed messages beyond the window are never fetched, so the effective model context silently shrinks well below MAX_HISTORY_MESSAGES. Filter completed messages in the query itself (or fetch a larger window) so skipped rows don't displace real history.

    where: { conversationId, id: LessThanOrEqual(focusUserMessageId), status: 'completed' },
    relations: { attachments: true },
    order: { createdAt: 'DESC', id: 'DESC' },
    take: MAX_HISTORY_MESSAGES + 1,


All attachment text (including the `[图片附件：…]`/`[附件：…]` markers) is merged into a single leading text part while every image is appended afterwards, destroying the original interleaving order of text and images in multi-part attachments (e.g., a scanned multi-page document where each page's OCR text should sit next to its image). The model receives all page texts before any image, which can degrade multimodal reasoning. Consider emitting text/image parts in their original sequence (one content part per attachment item) instead of collecting all text first.

return [{ type: 'text', text: combinedText }, ...contentParts];


Minor inconsistency: the persisted user content uses `dto.message.trim()`, but the auto-generated title uses the untrimmed `dto.message`. If the input has leading/trailing whitespace, the title may retain it while the stored content does not. Use the trimmed value consistently.

conversation.title === DEFAULT_TITLE ? context.titleFromMessage(dto.message.trim()) : undefined,

conversation.title === DEFAULT_TITLE ? context.titleFromMessage(dto.message) : undefined,


Organization IDs are only resolved when `raw` is an actual JS number, but the caller passes `row.organization` from AI-extracted rows (`ai-review.import-basic.ts` line 37), where values are typically strings. A numeric string such as "3" will never be matched against `org.id` and silently falls through to the name/code lookup, then to `null`/host fallback. This inconsistency makes the id resolution unreliable for the primary use case. Consider also matching numeric strings against `org.id`.

  if (typeof raw === 'number') {
    return organizations.some((org) => org.id === raw) ? raw : null;
  }
  const text = typeof raw === 'string' ? raw.trim() : '';
  if (/^\d+$/.test(text)) {
    const id = Number(text);
    return organizations.some((org) => org.id === id) ? id : null;
  }


Any empty/missing value (empty string, `undefined`, `null`, or even unexpected types like booleans/objects, since they all fall into `!text`) is silently resolved to the host organization without any warning. In the caller (`ai-review.import-basic.ts`), the warning "机构无法识别，已按本机构导入" is only emitted when `null` is returned, so data rows with a missing/garbage organization value are imported under the host org with no signal to the user. If silent defaulting is not intended for empty values, return `null` for empty input and let the caller decide (including emitting a warning).

  if (!text) {
    return organizations.find((org) => org.isHost)?.id ?? null;
  }


`AiReviewSection.type` is declared as the non-nullable union `AiReviewSectionType = 'students' | 'rooms' | 'transfers' | 'checkins'` (see ai-review.entity.ts), so the first `if` here always evaluates to true and `reviewSectionType` always returns `section.type`. The entire fallback below (the `section.key` cast, the `startsWith` loop and the `BadRequestException`) is dead code given the current types. Either this function is meant to tolerate legacy/malformed data — in which case the parameter type should be widened (e.g. `type?: AiReviewSectionType | null`) — or the unreachable fallback should be removed. Also note the literal `['students', 'rooms', 'transfers', 'checkins']` (and its four-way comparisons) is duplicated three times; extract a shared `const` (e.g. `REVIEW_SECTION_TYPES`) to keep the accepted values in sync.

  if (
    section.type === 'students' ||
    section.type === 'rooms' ||
    section.type === 'transfers' ||
    section.type === 'checkins'
  ) {
    return section.type;
  }


This is a `.types.ts` file, but most imports are runtime (value) imports even though they are only used in type positions (`DataSource`, `Repository`, `AiConfigService`, `AgentToolExecutor`, `AgentToolContextFactory`, `AiAttachmentService`, `ImportsService`, `AiChartService`, `AiExcelReaderService`, `AiFormService`, `AiReviewService`, `AiModelStreamService`, `OperationLogsService`, and the entity classes/types). Anyone importing from this types file will therefore eagerly load all those service modules, which risks circular-dependency issues and unnecessary module initialization. Only `BadRequestException` (thrown by `reviewSectionType`) needs a value import; convert the rest to `import type` (note `AgentToolContextFactory` is only used in `typeof ...` position, so `import type` is sufficient there too).

import { DataSource, Repository } from 'typeorm';
import { AiConfigService } from '../ai-config/ai-config.service';
import { AgentToolExecutor } from '../agent-tools/agent-tool.executor';
import { AgentToolContextFactory } from '../agent-tools/agent-tool.types';


This catch block swallows the original error and reports "表单参数无效" (invalid form parameters) for ANY failure — DB errors, missing assistant message, metadata serialization failures, etc. — with no logging of the real cause. This misleads the model/user about the actual failure and makes server-side debugging nearly impossible. Capture the error (e.g., `catch (error)`) and log it; only use the "参数无效" message when the failure is genuinely an argument/validation error.

  } catch {
    await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: '表单参数无效', error: '表单参数无效' }, emit);
    return JSON.stringify({ status: 'failed', error: '表单参数无效' });
  }


Same issue as executeRenderForm: the catch reports "图表参数无效" for every failure (DB error, missing assistant message, chart serialization error, etc.) and discards the actual error without logging it. Swallow-and-remap hides real bugs and misleads the model. Log the underlying error and only classify it as an argument error when appropriate.

  } catch {
    await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: '图表参数无效', error: '图表参数无效' }, emit);
    return JSON.stringify({ status: 'failed', error: '图表参数无效' });
  }


Nested ternary expression — prohibited by the project's review rules. The inner `existingCharts ? [existingCharts] : []` branch is hard to read and error-prone. Rewrite with if/else branches (or extract a small helper) for clarity.

    let charts: unknown[];
    if (isUnknownArray(existingCharts)) {
      charts = [...existingCharts];
    } else if (existingCharts) {
      charts = [existingCharts];
    } else {
      charts = [];
    }

    const charts = isUnknownArray(existingCharts)
      ? [...existingCharts]
      : existingCharts
        ? [existingCharts]
        : [];


executeRenderForm and executeRenderChart share nearly the same skeleton: look up assistant message → merge/save metadata → finishToolRun(success) → emit('ui.artifact') → return success JSON, plus two identical catch blocks that remap errors to a fixed '参数无效' message. Consider extracting a shared helper (e.g., a generic save-and-emit-artifact routine) to avoid drift between the two implementations.

export async function executeRenderChart(
  context: AiChatServiceContext,
  messageId: number,
  call: ModelToolCall,
  emit: AiSseEmitter,
): Promise<string> {


The size guard is labeled `MAX_VALUES_BYTES` (64 KB) but `serialized.length` counts UTF-16 code units, not bytes. With CJK input (common in this app, cf. the Chinese messages) a value up to ~192 KB UTF-8 can be stored, and `Buffer.byteLength` is what actually matches the stated byte limit. Either use `Buffer.byteLength(serialized, 'utf8')` or rename the constant to reflect that it's a character limit.

if (Buffer.byteLength(serialized, 'utf8') > MAX_VALUES_BYTES) throw new BadRequestException('提交内容过长');

if (serialized.length > MAX_VALUES_BYTES) throw new BadRequestException('提交内容过长');


`markSubmitted` performs an unconditional save without re-checking `status`. `findOwnedPending` checks `status: 'pending'` earlier, but between that read and this save another request can also pass the check (double submit) or the form can be expired via `expirePreviousForms`, and this save will still overwrite the row. Use a conditional update on `{ id, status: 'pending' }` and reject the request when the affected row count is 0 to make the state transition atomic.

  async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {
    const result = await this.forms.update(
      { id: form.id, status: 'pending' },
      {
        status: 'submitted',
        submittedValuesJson: JSON.stringify(values),
        submittedAt: new Date(),
      },
    );
    if (!result.affected) throw new BadRequestException('表单不存在、已提交或已失效');
    form.status = 'submitted';
    form.submittedValuesJson = JSON.stringify(values);
    form.submittedAt = new Date();
    return form;
  }

  async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {
    form.status = 'submitted';
    form.submittedValuesJson = JSON.stringify(values);
    form.submittedAt = new Date();
    return this.forms.save(form);
  }


This find-then-update sequence is not atomic: a form that is submitted (or otherwise transitioned) concurrently between the `find` and the `update` will be force-marked `expired`, losing a legitimate submission. Narrow the update condition to only pending rows (e.g. add `status: 'pending'` to the where clause) so already-submitted forms cannot be overwritten, or use a single conditional UPDATE instead of read-then-write.

await this.forms.update({ id: In(ids), status: 'pending' }, { status: 'expired' });

    const expired = pending.filter((form) => form.id !== exceptFormId);
    if (expired.length === 0) return [];
    const ids = expired.map((form) => form.id);
    await this.forms.update({ id: In(ids) }, { status: 'expired' });


`DATE_RE` only checks the shape `YYYY-MM-DD`, so impossible dates such as `2023-13-40` or `2023-02-30` are accepted. Construct a `Date` and verify the round-trip (e.g. `d.toISOString().slice(0, 10) === value`) to reject non-existent calendar dates.

    if (field.type === 'date' && !DATE_RE.test(value)) {
      throw new BadRequestException(`「${field.label}」必须是 YYYY-MM-DD 格式`);
    }


`FIELD_NAME_RE` (`/^[a-zA-Z0-9_]{1,50}$/`) permits names like `__proto__`, and `result[field.name] = ...` writes into a plain object. Assigning `result['__proto__'] = value` does not create an own property (setting the prototype to a primitive is silently ignored), so that field's submitted value is silently dropped and the serialized payload is inconsistent with the schema. Use a null-prototype object (`Object.create(null)`) for `result`, or reject reserved prototype property names during schema validation.

const result: Record<string, unknown> = Object.create(null);

const result: Record<string, unknown> = {};


The `truncated` flag semantics are inverted. `slice.length < limit` means the end of the sheet was reached (i.e., nothing was cut off), while the truly truncated case is when exactly `limit` rows were returned but the sheet still has more rows (`from + limit < sheet.rows.length`). As written, the last page reports `truncated: false` and an earlier partial page can misreport, which can mislead the AI into stopping early or reading past the end.

truncated: from + limit < sheet.rows.length,


The doc comment promises a "short sample, small enough for prompts", but this implementation serializes every row of every sheet into `text`. For large workbooks this can produce enormous prompts (token/memory blow-up) and defeats the stated design of reading sheets on demand. Consider limiting the overview to a sample (e.g., the first N rows per sheet) and leaving the full dump to `extractText`.

  /** Sheet list + row counts + a short sample, small enough for prompts. */
  async overview(buffer: Buffer): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {


This empty catch block silently swallows every ExcelJS failure (corrupt file, invalid zip, unexpected library bug, etc.) and hides it behind the fallback. If the fallback also throws, the original root cause is lost, making production debugging very difficult. Log the original error before falling back, or narrow the catch to the expected WPS-style namespace failure.

    } catch {
      return this.loadWithFallback(buffer);
    }


Every `readRows` (and `overview`/`extractText`) call re-parses the entire workbook and stringifies every cell via `loadSheets`, even though only a small slice is requested. For large Excel files this is O(file size) work per page request and materializes all rows in memory, contradicting the "reads sheets on demand" design. Consider caching the parsed result per buffer (e.g., `WeakMap<Buffer, Promise<ExcelSheetRows[]>>`) or parsing only the requested sheet/range.

    const sheets = await this.loadSheets(buffer);
    const sheet = sheets.find((item) => item.name === sheetName) ?? sheets[0];


Possible double-finish of the tool run: `finishToolRun` is called with `status: 'success'` first (persisting success + emitting `tool.completed`), and then `emit('ui.import_wizard')` / `emit('ui.artifact')` are called still inside the `try` block of `runImportTool`. If either SSE emit throws (e.g. connection closed), the catch block calls `finishToolRun` a second time with `status: 'failed'`, overwriting the already-saved success record and emitting a contradictory `tool.failed` after `tool.completed`; the caller also receives a `failed` JSON even though the wizard was created and persisted. Recommend emitting the UI events before the success `finishToolRun`, or moving them outside the error-handling path / marking the run as finished so the catch cannot re-finish it.

    await finishToolRun(context, run, call, startedAt, {
      status: 'success',
      summary: `已生成导入向导：${detail.steps
        .filter((step) => step.status !== 'skipped')
        .map((step) => step.label)
        .join('、')}`,
    }, emit);
    emit('ui.import_wizard', { messageId, wizard });


`parsedRecord.stages` is cast directly to `ImportStageRequest[]` without verifying each element is a non-null object. If the model returns stages containing e.g. `null`, a number or a string, `stage.stepKey` (and `expandStageSheets(stage)`) will throw a generic `TypeError` that is only caught by the outer catch in `runImportTool`, so the user gets an opaque failure instead of the intended validation message. Add an element-level guard before accessing `stage.stepKey`/`expandStageSheets` (e.g. `typeof stage === 'object' && stage !== null`).

    for (const stage of stages) {
      if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) {


The catch block swallows the original error without logging it anywhere, which makes server-side debugging of failed import-wizard runs difficult. Additionally, `finishToolRun` inside this catch can itself throw (e.g. DB save failure or SSE emit failure); in that case the error propagates unhandled and the intended failed JSON response is never returned. Consider logging the original error and wrapping the failure finalization so a response is still produced.

  } catch (error) {
    const summary = error instanceof Error ? error.message.slice(0, 100) : `${toolName} 失败`;
    await finishToolRun(context, run, call, startedAt, {
      status: 'failed',
      summary,
      error: summary,
    }, emit);
    return JSON.stringify({ status: 'failed', error: run.resultSummary });


The `AiReviewSectionResult` union declares the same member twice (`{ created: ... }` in both branches) — the second branch is a copy-paste typo and should be `{ completed: ... }`. As written, the union collapses to a single shape, so the `completed` variant is no longer part of the type: `importTransfers`/`importCheckins`/`parseStoredSectionResult` return `{ completed, ... }` objects that are not assignable to this type, and callers in `mergeStepResult`/`sectionResultMessage` are forced to use unsafe `as` casts. Fix the second member.

export type AiReviewSectionResult =
  | { created: number; skipped: number; issues: string[] }
  | { completed: number; skipped: number; issues: string[] };

export type AiReviewSectionResult =
  | { created: number; skipped: number; issues: string[] }
  | { created: number; skipped: number; issues: string[] };


`DATE_RE` only checks the YYYY-MM-DD *format*, not calendar validity — strings like "2026-02-31" or "2026-13-45" pass and are returned as valid dates. Downstream this value is stored directly into date columns (`checkOutDate`/`checkInDate`) and compared as a string, so an impossible date can be persisted or silently rolled over (e.g. by dayjs) into a different day, corrupting the data. Consider validating the parsed date components (e.g. reconstruct a UTC Date and check year/month/day round-trip) before returning.

export function toDateString(value: unknown): string | null {
  if (typeof value === 'string' && DATE_RE.test(value.trim())) return value.trim();
  return null;
}


The final fallback branch of `sectionResultMessage` silently assumes any key that is not students/rooms/transfers is `checkins` (the default `return` is the check-in message). If a new section type is added to `SECTION_TYPES`/`SECTION_ORDER`, it will be mislabeled with the check-in message instead of failing loudly. Make the `checkins` branch explicit and throw (or otherwise handle) unknown keys for clarity and future safety.

  if (key === 'transfers') {
    return `成功换宿 ${(result as { completed: number }).completed} 条，跳过 ${result.skipped} 条`;
  }
  return `成功入住 ${(result as { completed: number }).completed} 条，跳过 ${result.skipped} 条`;


`AiReviewStepSubmitResult` and `AiReviewSubmitResult` are pure interfaces (defined as `interface`/`type` in ai-review.shared.ts) and are used only in type positions. They are imported as value imports here, which is inconsistent with the `import type` style used everywhere else in this file. Under TS configs with `verbatimModuleSyntax`/`isolatedModules` this fails to compile, and a bundler would try to resolve a non-existent runtime export. Use a type-only import.

import type {
  AiReviewStepSubmitResult,
  AiReviewSubmitResult,
} from './ai-review.shared';

import {
  AiReviewStepSubmitResult,
  AiReviewSubmitResult,
} from './ai-review.shared';


`findOwnedPending` and the `findOwned` method below are nearly identical (same lookup, different `status` filter and error message), and `findOwned` also duplicates the exported `findOwned` helper in ai-review.submit.ts. Consider extracting a single private helper, e.g. `findOwned(reviewId, userId, status?)`, so the lookup/not-found logic is defined once and the two public methods only add their specific filter/message.

  async findOwnedPending(reviewId: string, userId: number): Promise<AiReview> {
    const review = await this.reviews.findOne({
      where: { id: reviewId, userId, status: 'pending' },
    });
    if (!review) throw new NotFoundException('导入预览不存在、已确认或已失效');
    return review;
  }


The `reviewSubmitted` parameter is accepted and forwarded all the way from the generation flow (`ai-chat.generation.ts` passes `Boolean(reviewSubmit)`) but is never referenced inside `executeTool`. The write-tool gate only checks `allowWriteTools`, so the review-submission state has no effect on tool execution. Either remove the dead parameter or use it in the guard (e.g., deny write tools unless the review has been submitted), otherwise the intended "review must be submitted first" policy is silently not enforced.


Write-tool names are hardcoded here and again in `denyWriteTool` ('create_student'). This is an authorization-relevant guard: any write tool registered later (e.g. delete_student, create_room) would bypass this deny-before-form-confirmation check because it is not in this literal list, and the two copies can drift. Derive the write-tool set from the tool registry/metadata (e.g., a `write` capability flag or `requiredPermission` prefix) instead of hardcoding tool names.

if ((call.name === 'create_student' || call.name === 'update_students') && !allowWriteTools) {


There is no try/catch around `context.toolExecutor.execute` or the subsequent `summarize`/`safeStructured`/`toolRuns.save` calls. `tool.started` has already been emitted and the DB row is saved with status 'running'; if any of these steps throws, the run is left permanently in 'running' state, no `tool.failed` event is emitted, and the error propagates without a tool result payload — breaking the SSE/message flow contract. Wrap the execution + persistence in try/catch and, on failure, mark the run 'failed', save it, emit `tool.failed`, and return a safe error JSON.

const result = await context.toolExecutor.execute(call.name, parsedArgs, agentContext, allowedSkillKey);


This finishing block (status/resultSummary/durationMs update, `toolRuns.save`, and the `tool.completed`/`tool.failed` emit) duplicates the logic already provided by `finishToolRun`, which is used by the tool-action modules. Reuse `finishToolRun` (extending it to also persist `resultData`/`skillKey` if needed) to avoid the two implementations drifting apart (e.g., one day only one of them is updated to include new fields/events).

run.resultSummary = context.summarize(result.result ?? result.error ?? null);


`context.safeStructured(parsedArgs)` returns `unknown`; the cast to `Record<string, unknown> | null` is unsound when the model supplies array-shaped tool arguments (the entity's `resultData` column explicitly allows `unknown[]`, so arrays are expected elsewhere). Casting only hides the type mismatch. Preserve the real shape (e.g., widen `AiToolRun.argumentsData` to `Record<string, unknown> | unknown[] | null` and let the value flow without a cast) so type errors surface instead of being suppressed.

(context.safeStructured(parsedArgs) as Record<string, unknown> | null),


The 32KB truncation check measures `modelPayload.length` (UTF-16 code units), not byte size. With Chinese/other multibyte content a payload can be up to ~3x the byte budget (96KB) while still passing this check, which may exceed the model context limit it is meant to protect. Use `Buffer.byteLength(modelPayload, 'utf8') <= 32 * 1024` (or an explicit byte-based limit) for the comparison.

if (modelPayload.length <= 32 * 1024) return modelPayload;


Inconsistent error handling: malformed JSON is silently treated as "no sections" (`return []`), while structurally invalid items throw BadRequestException. If `sectionsJson` in the DB is corrupted/truncated, callers such as `submitAll` (ai-review.submit.ts) will get an empty array and proceed as if the review is fully submitted, silently masking data loss. Recommend throwing a BadRequestException here as well (or applying a single consistent error policy) so corrupt data is surfaced instead of swallowed.

  } catch {
    throw new BadRequestException('导入预览数据解析失败');
  }
  if (!Array.isArray(parsed)) {
    throw new BadRequestException('导入预览数据格式无效');
  }

  } catch {
    return [];
  }
  if (!Array.isArray(parsed)) return [];


Only `typeof item.key === 'string'` is validated here, but the shared module defines `SECTION_KEY_RE = /^[a-zA-Z0-9_]{1,50}$/` and `submitSection` (ai-review.submit.ts) rejects keys that fail this regex. A key that starts with a valid type prefix (e.g., `students_`) but is longer than 50 chars or contains invalid characters passes `parseSections` and is persisted, yet can never be submitted via the API afterwards. Validate the key against `SECTION_KEY_RE` at parse time for consistency.

    if (
      !isPlainRecord(item) ||
      typeof item.key !== 'string' ||
      !SECTION_KEY_RE.test(item.key)
    ) {
      throw new BadRequestException('导入预览分表格式无效');
    }

    if (!isPlainRecord(item) || typeof item.key !== 'string') {
      throw new BadRequestException('导入预览分表格式无效');
    }


`columns`/`rows`/`issues` are only checked with `Array.isArray`; their element shapes are not validated. The persisted JSON is AI-generated/user-supplied, so e.g. rows may contain nested objects/arrays that violate `AiReviewRow` (scalar values only), columns may lack `key`/`title` strings, and issues may contain non-strings. This unvalidated data flows directly into section import and frontend rendering, risking runtime errors. Consider validating element types (or at least normalizing them) here.

      columns: Array.isArray(section.columns) ? section.columns : [],
      rows: Array.isArray(section.rows) ? section.rows : [],
      issues: Array.isArray(section.issues) ? section.issues : [],


After `Array.isArray(parsed)`, `parsed` is narrowed to `any[]`, so `item` here is implicitly `any`. Per the project's type rules (`any` should be avoided or annotated with a reason), type the array explicitly, e.g. cast to `unknown[]` before `map` and narrow inside the callback.

return (parsed as unknown[]).map((item) => {


同一学生在同一批次中，如果一行只填手机号、另一行只填学号（或一行同时填了学号+手机号），去重 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.