Files
gongxue-base/ocr-reports/all-comments.jsonl
wangziqi 1289aea7ce docs(ocr): 全库 OCR 审查与修复记录
由 OCR(open-codereview.ai,deepseek-v4-flash)审查产出:
- 审查汇总.md / high-severity-comments.md / all-comments.jsonl / 修复记录.md 等

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00

1626 lines
1.3 MiB
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{"path":"apps/admin/src/components/EditableCell/style.css","start_line":40,"end_line":43,"category":"maintainability","severity":"medium","content":"Hardcoded hex colors (#1677ff, #91caff, #e6f4ff) are inconsistent with the rest of the admin app, which uses CSS design tokens (e.g. `var(--ink)`, `var(--line)`, `var(--blue)` in attendance.css). If the antd theme or dark mode is customized elsewhere, this component will visually diverge and won't respond to theme changes. Consider defining/using theme tokens (or `var(--ant-color-primary)`, `var(--ant-color-primary-bg)` etc.) instead of hardcoding colors.","suggestion_code":null,"existing_code":".editable-cell--enabled:not(.editable-cell--editing):hover {\n border-color: #91caff;\n background: #e6f4ff;\n}"}
{"path":"apps/admin/src/components/EditableCell/style.css","start_line":8,"end_line":14,"category":"bug","severity":"medium","content":"The display container sets `min-width: 0; width: 100%` but never constrains its content. Long cell values (e.g. long text/tags) will force the table column to expand or overflow the cell instead of truncating, which can break table layout. Consider adding `overflow: hidden; text-overflow: ellipsis; white-space: nowrap;` (or `word-break`) to the display span so content stays bounded while still showing the undo button.","suggestion_code":".editable-cell-display {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}","existing_code":".editable-cell-display {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n min-width: 0;\n width: 100%;\n}"}
{"path":"apps/admin/src/components/EditableCell/style.css","start_line":27,"end_line":29,"category":"bug","severity":"low","content":"The undo button only provides a hover indicator (`text-decoration: underline`) but no `:focus-visible` style, and the editable cell's hover affordance (`border-color`/`background`) has no keyboard-equivalent focus style either. Keyboard/touch users get no visible indication of interactivity. Add a `:focus-visible` rule (e.g. outline) for the undo button and the enabled cell.","suggestion_code":null,"existing_code":".editable-cell-undo:hover {\n text-decoration: underline;\n}"}
{"path":"apps/admin/src/components/EditableCell/style.css","start_line":64,"end_line":66,"category":"maintainability","severity":"low","content":"`!important` on `.editable-cell-dropdown` is a code smell and makes overrides hard to trace; it may also conflict if `popupMatchSelectWidth={false}` is used (dropdown width no longer tracks the trigger). Prefer a more specific selector or an antd token/`styles` prop to set the dropdown width without `!important`.","suggestion_code":null,"existing_code":".editable-cell-dropdown {\n min-width: 140px !important;\n}"}
{"path":"apps/admin/src/components/AiChat/style.css","start_line":348,"end_line":353,"category":"bug","severity":"medium","content":"Removing `outline` and `box-shadow` on `:focus` (including `:focus-visible`) completely removes the visible focus indicator for keyboard users, which is an accessibility violation (WCAG 2.4.7). If the goal is only to suppress the default browser outline in favor of a custom style, keep `:focus-visible` styles instead of killing them entirely — e.g. `outline: none` only on mouse-driven `:focus` but provide an equivalent visible focus ring for `:focus-visible`.","suggestion_code":".ai-chat-composer .ant-sender-input:focus:not(:focus-visible) {\n outline: none;\n box-shadow: none;\n}","existing_code":".ai-chat-composer .ant-sender-input:focus,\n.ai-chat-composer .ant-sender-input:focus-visible,\n.ai-chat-composer .ant-sender-input:focus-within {\n outline: none;\n box-shadow: none;\n}"}
{"path":"apps/admin/src/components/AiChat/style.css","start_line":247,"end_line":262,"category":"bug","severity":"medium","content":"`.ai-chat-hover-actions` is hidden with `opacity: 0` + `pointer-events: none`, but it stays in the DOM and remains in the keyboard tab order, so interactive elements inside (copy/edit buttons) can be focused while invisible — screen readers will also announce hidden controls. Also, on touch devices there is no hover, and `.ant-bubble` is not focusable, so the `:focus-within` fallback can never trigger, making these actions unreachable. Use `visibility: hidden` when hidden (which removes it from the a11y tree and tab order) and `visibility: visible` on hover/`focus-within`, and/or make the bubble itself focusable for touch/keyboard access.","suggestion_code":null,"existing_code":".ai-chat-hover-actions {\n display: inline-flex;\n align-items: center;\n gap: 2px;\n padding: 3px;\n background: rgba(255, 255, 255, 0.94);\n border: 1px solid #eceef2;\n border-radius: 8px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);\n opacity: 0;\n transform: translateY(-3px);\n transition:\n opacity 0.15s ease,\n transform 0.15s ease;\n pointer-events: none;\n}"}
{"path":"apps/admin/src/components/AiChat/style.css","start_line":441,"end_line":444,"category":"maintainability","severity":"low","content":"Using `!important` to override the margin is fragile and makes later overrides harder; prefer a higher-specificity selector or an antd token/`styles` prop instead of `!important`.","suggestion_code":null,"existing_code":".ai-chat-review-card__summary {\n margin: 4px 0 8px !important;\n font-size: 12px;\n}"}
{"path":"apps/admin/src/components/AiChat/style.css","start_line":372,"end_line":378,"category":"maintainability","severity":"low","content":"On mobile the sidebar becomes `position: absolute`, so `flex-basis` no longer affects its layout and only `width` matters — but `width` is not transitioned (only `flex-basis` is). The result is that opening/closing the drawer on small screens jumps instantly instead of animating, inconsistent with the desktop behavior. Add a `width` transition (e.g. `transition: flex-basis 180ms ease, width 180ms ease;`) so mobile matches.","suggestion_code":" .ai-chat-sidebar {\n position: absolute;\n z-index: 2;\n inset: 0 auto 0 0;\n box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);\n transition: width 180ms ease;\n }","existing_code":"@media (max-width: 575px) {\n .ai-chat-sidebar {\n position: absolute;\n z-index: 2;\n inset: 0 auto 0 0;\n box-shadow: 8px 0 24px rgba(0, 0, 0, 0.08);\n }"}
{"path":"apps/admin/src/pages/Exams/style.css","start_line":41,"end_line":45,"category":"maintainability","severity":"medium","content":"The ellipsis truncation is bound to `span:last-child` inside the card title, which is fragile and structure-dependent. If a title is plain text (no span wrapper) the rule silently does nothing; if the last child happens to be a status tag/badge/icon rather than the text, the overflow/ellipsis is applied to the wrong element. Consider introducing a dedicated class (e.g. `.exam-card-title-text`) for the truncatable text and applying `min-width: 0` explicitly to it, instead of relying on `:last-child`.","suggestion_code":null,"existing_code":".exam-card .ant-card-head-title span:last-child {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}"}
{"path":"apps/admin/src/pages/Exams/style.css","start_line":47,"end_line":59,"category":"maintainability","severity":"medium","content":"These rules target generic elements (`> div`, `span`) inside `.exam-progress`, so they may unintentionally restyle Ant Design Progress internals (the `.ant-progress` root div and the `.ant-progress-text` percentage span are both matched). If the row markup isn't exactly `<div><span>label</span><Progress/></div>`, the `display:flex / space-between` can distort the progress bar layout and the percentage text gets greyed. `margin-bottom: 10px` is also applied to the last row, adding trailing space. Scope these styles to an explicit row class (e.g. `.exam-progress-row` / `.exam-progress-row span`) so they only affect intended elements.","suggestion_code":null,"existing_code":".exam-meta,\n.exam-progress > div {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n margin-bottom: 10px;\n}\n\n.exam-meta span,\n.exam-progress span {\n color: rgba(0, 0, 0, 0.55);\n}"}
{"path":"apps/admin/src/pages/Exams/style.css","start_line":83,"end_line":85,"category":"other","severity":"low","content":"Accessibility: `#ff4d4f` on a white background has a contrast ratio of roughly 3.3:1, below the WCAG AA 4.5:1 threshold for normal-size text. For a destructive-action label this may be hard to read, especially at small font sizes. Consider a darker danger tone (e.g. `#cf1322`) for text, while keeping the brighter red for borders/backgrounds.","suggestion_code":null,"existing_code":".exam-purge-action {\n color: #ff4d4f;\n}"}
{"path":"apps/admin/src/index.css","start_line":557,"end_line":559,"category":"bug","severity":"medium","content":"This later media block overrides the earlier mobile `.route-dock` rule (`height: 42px; padding: 5px 8px`) with `padding: 6px 8px` but never adjusts the height. With the global `box-sizing: border-box` (and the 1px `border-bottom`), the content area becomes 42 - 6 - 6 - 1 = 29px, while `.route-dock .ant-tabs` / `.ant-tabs-tab` remain 32px tall. Since `.route-dock` has `overflow: hidden`, the bottom ~3px of the tabs (active-state border-radius/shadow) gets clipped. Keep `padding: 5px 8px` or increase the height to 44px to match the 32px tabs.","suggestion_code":" .route-dock {\n height: 44px;\n padding: 6px 8px;\n }","existing_code":" .route-dock {\n padding: 6px 8px;\n }"}
{"path":"apps/admin/src/index.css","start_line":561,"end_line":565,"category":"maintainability","severity":"low","content":"This is the third `@media (max-width: 575px)` block in the file, and it silently overrides earlier rules with the same specificity: the earlier `.route-dock .ant-tabs-tab { min-width: 104px; }` and `.route-dock { height: 42px; padding: 5px 8px; }` become dead rules once this block wins the cascade. Editing the earlier block would have no visible effect, which is error-prone. Consolidate all `max-width: 575px` rules into a single media block (or one shared source of truth) so the effective values are unambiguous.","suggestion_code":null,"existing_code":" .route-dock .ant-tabs-tab {\n min-width: 88px;\n max-width: 160px;\n padding: 0 8px 0 10px !important;\n }"}
{"path":"apps/admin/src/index.css","start_line":477,"end_line":478,"category":"bug","severity":"low","content":"This pseudo-element injects the text '表格可左右滑动查看' above every table on mobile, even tables that fit their container and never need horizontal scrolling. It also exposes the hint to screen readers on all tables (pseudo-element content is read aloud). Gate it behind an explicit modifier class (e.g. only when a `.table-overflow-hint` class is added) or only render it when the table actually overflows.","suggestion_code":null,"existing_code":" .ant-table-wrapper::before {\n content: '表格可左右滑动查看';"}
{"path":"apps/admin/src/index.css","start_line":280,"end_line":285,"category":"bug","severity":"low","content":"`100dvh` is unsupported in older browsers (Chrome < 108, Safari < 15.4, Firefox < 101), so this `min-height` (and the same pattern in `.ant-modal .ant-modal-body { max-height: calc(100dvh - 180px) }`) is silently dropped on those engines, leaving the layout without the intended sizing. Add a `100vh` fallback line before the `dvh` declaration so older browsers still get a sensible value.","suggestion_code":" .app-content {\n min-height: calc(100vh - 72px);\n min-height: calc(100dvh - 72px);\n margin: 8px !important;\n padding: 12px !important;\n border-radius: 10px !important;\n }","existing_code":" .app-content {\n min-height: calc(100dvh - 72px);\n margin: 8px !important;\n padding: 12px !important;\n border-radius: 10px !important;\n }"}
{"path":"apps/admin/src/pages/Attendance/attendance.css","start_line":744,"end_line":747,"category":"bug","severity":"medium","content":"`position: sticky` on the topbar will not work: the nearest scroll container for the sticky element is `.student-attendance-center`, because that element has `overflow: hidden` (any non-`visible` overflow creates a scroll container). Since that container never actually scrolls (its height grows with the content, min-height: calc(100vh - 64px)), the topbar will simply scroll away with the page instead of sticking to the top of the viewport. Fix by removing `overflow: hidden` from `.student-attendance-center` (use `overflow: clip` if clipping is only there to hide decoration overflow, since `clip` does not create a scroll container) or by moving the sticky element outside that ancestor.","suggestion_code":null,"existing_code":".student-center-topbar {\n position: sticky;\n top: 0;\n z-index: 12;"}
{"path":"apps/admin/src/pages/Attendance/attendance.css","start_line":845,"end_line":851,"category":"bug","severity":"low","content":"`--student-line` (and the other `--student-*` custom properties) are only declared on `.student-attendance-center`. The period editor is used inside `.attendance-period-modal`, and Ant Design modals render through a portal into `document.body` by default, which breaks custom-property inheritance. If the modal is portaled, `var(--student-line)` is invalid at computed-value time and the whole `border` declaration is dropped, leaving the rows borderless. Note the neighboring rule `.attendance-correction-segment` already uses a fallback (`var(--student-line, #e1e8e5)`) — apply the same fallback here (and to the other `--student-*` references), or define these variables on a common ancestor that includes the modal.","suggestion_code":null,"existing_code":".attendance-period-row {\n display: grid;\n grid-template-columns: 1.05fr 1fr 132px 132px 92px auto;\n gap: 10px;\n align-items: start;\n padding: 12px;\n border: 1px solid var(--student-line);"}
{"path":"apps/admin/src/api/schemas/dashboard.ts","start_line":91,"end_line":91,"category":"maintainability","severity":"low","content":"This file ends with a dangling documentation comment `/** 考勤元数据 */` (attendance metadata) but no corresponding schema was ever defined below it. This looks like unfinished work; either implement the schema it refers to or remove the comment to avoid implying a schema exists that doesn't.","suggestion_code":null,"existing_code":"/** 考勤元数据 */"}
{"path":"apps/admin/src/api/schemas/dashboard.ts","start_line":60,"end_line":63,"category":"maintainability","severity":"medium","content":"`ganttRoomsSchema` is the only schema in this file that does not end with `.passthrough()`. All other schemas (classroomSchedule, dashboardStats, roomRanking, classAttendanceRanking, classroomOccupancies, classroomUtilStats) explicitly preserve unknown fields, indicating the API responses may carry extra properties. Without `.passthrough()`, any additional field returned by the API (e.g. a room `id` or extra occupancy metadata) is silently stripped when parsing, and the parsed value can diverge from the raw payload. Add `.passthrough()` to both the array item and the `occupancies` item objects for consistency and to avoid silent data loss.","suggestion_code":"export const ganttRoomsSchema = z.array(\n z\n .object({\n roomNumber: z.string(),\n occupancies: z.array(\n z\n .object({\n studentName: z.string(),\n studentId: z.union([z.string(), z.number()]).optional(),\n checkInDate: z.string(),\n checkOutDate: z.string().nullable(),\n billingStartDate: z.string().optional(),\n billingEndDate: z.string().nullable().optional(),\n })\n .passthrough(),\n ),\n })\n .passthrough(),\n);","existing_code":"export const ganttRoomsSchema = z.array(\n z\n .object({\n roomNumber: z.string(),"}
{"path":"apps/admin/src/api/schemas/dashboard.ts","start_line":47,"end_line":51,"category":"maintainability","severity":"low","content":"The `top` and `bottom` item schemas in `classAttendanceRankingSchema` are byte-for-byte identical. Extract the item schema into a shared constant (e.g. `const attendanceRankingItemSchema = z.object({...}).passthrough()`) and reuse it in both arrays to avoid duplicated definitions drifting apart.","suggestion_code":null,"existing_code":" top: z.array(\n z\n .object({ className: z.string(), present: z.number(), total: z.number(), rate: z.number() })\n .passthrough(),\n ),"}
{"path":"apps/admin/src/api/index.ts","start_line":21,"end_line":21,"category":"maintainability","severity":"medium","content":"Hardcoded login URL detection with duplicated variants ('/auth/login' vs 'auth/login'). This is fragile: it breaks if the request URL includes query params, a trailing slash, or a deployment sub-path, and in those cases a failed login (401) would incorrectly trigger logout + redirect. Extract a normalized login-path constant (e.g. strip leading slash and query string) instead of hardcoding two string literals.","suggestion_code":null,"existing_code":" const isLoginRequest = err.config?.url === '/auth/login' || err.config?.url === 'auth/login';"}
{"path":"apps/admin/src/api/index.ts","start_line":13,"end_line":13,"category":"bug","severity":"low","content":"Setting config.headers.Authorization without a null check can throw a TypeError if a caller passes a config whose headers is undefined/null. Add a defensive initialization before assigning.","suggestion_code":" config.headers = config.headers ?? {};\n config.headers.Authorization = `Bearer ${token}`;","existing_code":" config.headers.Authorization = `Bearer ${token}`;"}
{"path":"apps/admin/src/api/index.ts","start_line":26,"end_line":26,"category":"maintainability","severity":"medium","content":"Hardcoded '/login' redirect via window.location.href causes a full page reload and breaks when the app is deployed under a sub-path (non-root base URL). Prefer router-based navigation and derive the login path from a shared constant or the app base URL (e.g. import.meta.env.BASE_URL) instead of hardcoding.","suggestion_code":null,"existing_code":" window.location.href = '/login';"}
{"path":"apps/admin/src/api/schemas/ai.ts","start_line":25,"end_line":27,"category":"bug","severity":"medium","content":"The envelope schema requires `data` to be a non-nullable `aiConfigSchema` and omits the optional `message` field, while the consuming page declares `ApiResponse<T> { success; data; message? }` (see pages/AiConfig/index.tsx). If the API ever returns an error envelope (`{ success: false, message: '...' }` without `data`) or a “no config saved yet” response (`{ success: true, data: null }`), `validateResponse` will throw a generic “接口字段 data 格式异常” and the page will treat it as a load failure instead of surfacing the server message/empty state. Consider making `data` nullable and declaring `message` as optional.","suggestion_code":"export const aiConfigEnvelopeSchema = z\n .object({\n success: z.boolean(),\n data: aiConfigSchema.nullable(),\n message: z.string().optional(),\n })\n .passthrough();","existing_code":"export const aiConfigEnvelopeSchema = z\n .object({ success: z.boolean(), data: aiConfigSchema })\n .passthrough();"}
{"path":"apps/admin/src/api/schemas/ai.ts","start_line":29,"end_line":29,"category":"maintainability","severity":"low","content":"This trailing `/** 导入任务 */` section header has no following schema — the file ends right after it, and no “import task” schema exists anywhere in the project (a codebase-wide search finds only this comment and unrelated `ImportWizard` UI code). This is a dangling/incomplete section marker that will confuse maintainers; either implement the intended import-task schema here or remove the comment.","suggestion_code":null,"existing_code":"/** 导入任务 */"}
{"path":"apps/admin/src/api/schemas/core.ts","start_line":243,"end_line":245,"category":"bug","severity":"medium","content":"This is the only schema in the file without `.passthrough()`. `validateResponse` (utils/validate.ts) calls `safeParse`, and Zod strips unknown keys by default — so any extra fields returned by `/expense-types` (e.g. `id`) are silently dropped, unlike every sibling schema here. Add `.passthrough()` for consistency and to avoid silent data loss.","suggestion_code":"export const expenseTypesSchema = z.array(\n z.object({ code: z.string(), name: z.string(), category: z.string() }).passthrough(),\n);","existing_code":"export const expenseTypesSchema = z.array(\n z.object({ code: z.string(), name: z.string(), category: z.string() }),\n);"}
{"path":"apps/admin/src/api/schemas/core.ts","start_line":275,"end_line":277,"category":"bug","severity":"medium","content":"Inconsistent nullability inside the weekly schedule item: `id` and `classId` are `z.number().nullable()` while `classroomId` is a required non-nullable `z.number()`. If an empty timetable cell is represented with a null `classroomId` (as the nullable `id`/`classId` suggest), validation will fail on every schedules-page fetch. Make `classroomId` nullable too, or confirm the API never returns null for it.","suggestion_code":" id: z.number().nullable(),\n classId: z.number().nullable(),\n classroomId: z.number().nullable(),","existing_code":" id: z.number().nullable(),\n classId: z.number().nullable(),\n classroomId: z.number(),"}
{"path":"apps/admin/src/api/schemas/core.ts","start_line":41,"end_line":43,"category":"maintainability","severity":"low","content":"The `{ id: z.number(), name: z.string() }` option shape is duplicated at least eight times in this file (`organizationOptionSchema`, `classroomOptionSchema`, `classOptionSchema`, `examDetailSchema`, `billSchema.student`, `rentalSchema.classroom`/`lesseeOrganization`, `scheduleLookupsSchema.classes`, `studentFilterLookupsSchema.classes/teachers`). Extract a shared option schema and reuse it to keep definitions consistent and easier to maintain.","suggestion_code":"export const idNameOptionSchema = z\n .object({ id: z.number(), name: z.string() })\n .passthrough();\n\nexport const organizationOptionSchema = idNameOptionSchema;","existing_code":"export const organizationOptionSchema = z\n .object({ id: z.number(), name: z.string() })\n .passthrough();"}
{"path":"apps/admin/src/api/imports.ts","start_line":36,"end_line":37,"category":"bug","severity":"medium","content":"Inconsistent response handling: unlike `getImportRun`, which validates the envelope via `validateResponse`/`importRunEnvelopeSchema`, this function (and `previewImportStep`/`commitImportStep`) returns `res.data` directly without checking `res.success`. If the backend reports a business failure as HTTP 200 with `{ success: false, message }`, these functions return whatever `data` holds (typically `undefined`) and drop the error message, so callers treat a failed create/preview/commit as success. Extract a shared helper that validates the envelope and throws when `success` is false (e.g., `unwrapEnvelope<T>(res: ApiEnvelope<T>): T`) and use it in all four functions for consistency.","suggestion_code":null,"existing_code":" const res = await api.post<ApiEnvelope<ImportRunDetail>>('/imports/runs', form, {\n onUploadProgress: (event) => {"}
{"path":"apps/admin/src/api/imports.ts","start_line":76,"end_line":79,"category":"maintainability","severity":"low","content":"The API base URL is hand-built here, hardcoding `http://localhost:${VITE_API_PORT || 3002}/api` in dev and duplicating the `baseURL: '/api'` already centralized in `api/index.ts`. The same literal is duplicated in `utils/download.ts` and the Classrooms page. If the Vite dev proxy target ever differs from `VITE_API_PORT`, or the app is accessed from a non-localhost host, the generated report/download link will silently point to the wrong server. Extract a shared `getApiBaseUrl()` helper (or reuse the axios instance's baseURL) and reuse it everywhere.","suggestion_code":null,"existing_code":"export function importErrorReportUrl(runId: string, stepKey?: string): string {\n const base = import.meta.env.PROD\n ? '/api'\n : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;"}
{"path":"apps/admin/src/api/queryKeys.ts","start_line":64,"end_line":65,"category":"maintainability","severity":"medium","content":"`permissionTree` and `permissionsTree` are two factories for the same resource but produce *different* keys (`['rbac','roles','permission-tree']` vs `['rbac','permissions','tree']`), so a query written against one would never be matched by an invalidation written against the other. Moreover, a codebase search shows the entire `queryKeys.rbac.*` namespace is currently unused (the Roles/Permissions pages don't reference these keys), i.e. dead code. Consolidate into a single, consistently-named key (and align the actual pages with it) to avoid a future cache-miss trap.","suggestion_code":null,"existing_code":" permissionTree: () => ['rbac', 'roles', 'permission-tree'] as const,\n permissionsTree: () => ['rbac', 'permissions', 'tree'] as const,"}
{"path":"apps/admin/src/api/queryKeys.ts","start_line":52,"end_line":53,"category":"bug","severity":"medium","content":"`organizations.list()` produces exactly the same key as `organizations.all` (`['organizations']`), which contradicts the file's own documented convention (root key `all` for bulk invalidation vs scoped list keys). Consequences: invalidating `all` always also hits `list`/`options` and vice versa, and `setQueryData(organizations.list(), ...)` writes to the root key, so the list can never be invalidated/refreshed independently of the rest of the module — a cache-crosstalk hazard the header comment warns about. Give the list its own segment, e.g. `['organizations', 'list']`.","suggestion_code":" all: ['organizations'] as const,\n list: () => ['organizations', 'list'] as const,","existing_code":" all: ['organizations'] as const,\n list: () => ['organizations'] as const,"}
{"path":"apps/admin/src/api/queryKeys.ts","start_line":88,"end_line":90,"category":"maintainability","severity":"low","content":"Same issue as `organizations.list`: `deposits.list()` returns `['deposits']`, identical to `deposits.all`, so list and root keys are indistinguishable and list invalidation/refetch always covers the whole module. This namespace is also currently unused, so it should be either removed or fixed to a distinct key.","suggestion_code":" deposits: {\n all: ['deposits'] as const,\n list: () => ['deposits', 'list'] as const,","existing_code":" deposits: {\n all: ['deposits'] as const,\n list: () => ['deposits'] as const,"}
{"path":"apps/admin/src/api/queryKeys.ts","start_line":31,"end_line":31,"category":"maintainability","severity":"low","content":"`dateRange` (and similarly `period` in `dashboard.summary`, `asOf` in `rooms.visual`, `classroomIds` in `classSchedules.list`, etc.) is typed as `unknown`, which defeats type checking at every call site that builds these keys — a typo in the shape (e.g. `{start: x, end: y}` vs `{from, to}`) silently changes the cache key instead of failing to compile. Define a concrete `DateRange` type (e.g. `{ start: string; end: string }`) and reuse it across these factories.","suggestion_code":" schedule: (id: number, dateRange: { start: string; end: string }) => ['classes', 'schedule', id, dateRange] as const,","existing_code":" schedule: (id: number, dateRange: unknown) => ['classes', 'schedule', id, dateRange] as const,"}
{"path":"apps/admin/src/api/schemas/integration.ts","start_line":23,"end_line":23,"category":"maintainability","severity":"low","content":"The file ends with an orphaned comment `/** 教室排课总览 */` (classroom scheduling overview) but no corresponding schema is defined below it. Either the intended schema is missing from this file, or this is leftover dead comment. Implement the schema or remove the comment to avoid misleading future readers.","suggestion_code":null,"existing_code":"/** 教室排课总览 */"}
{"path":"apps/admin/src/api/schemas/integration.ts","start_line":13,"end_line":16,"category":"maintainability","severity":"medium","content":"`.passthrough()` on both the response envelope and each array item, combined with `config: z.record(z.string(), z.unknown())`, silently accepts unknown keys and leaves all config values as untyped `unknown`. This largely defeats the purpose of response validation: a renamed/removed field (e.g. `verify` → `verified`) or a changed config shape would pass validation silently and only fail later when consumers cast the value (as seen in `IntegrationConfig/index.tsx`, which re-declares the shape as a generic and casts `config` to `DingTalkConfig`). Consider using `.strict()` for the known envelope/item shapes and a discriminated union on `type` with typed config, and export the inferred types (`z.infer<typeof ...>`) so consumers don't have to redeclare the contract.","suggestion_code":null,"existing_code":" .passthrough(),\n ),\n })\n .passthrough();"}
{"path":"apps/admin/src/api/schemas/import-run.ts","start_line":71,"end_line":71,"category":"maintainability","severity":"low","content":"This trailing `/** 考勤记录 */` (attendance record) comment is unrelated to import-run schemas and appears to be a leftover from copy-paste. It serves no purpose at the end of the file and should be removed.","suggestion_code":null,"existing_code":"/** 考勤记录 */"}
{"path":"apps/admin/src/api/schemas/attendance.ts","start_line":53,"end_line":53,"category":"bug","severity":"high","content":"Schema mismatch with the actual API response. The server's `/attendance-records/alerts` endpoint (`getAlerts` in attendance-report.service.ts) returns items shaped `{ studentId, studentName, className, type, count, lastDate }` (and the admin `AlertItem` type also declares `studentNo`), but this schema requires `id` and `message`, which the API never returns. Since these fields are required in Zod, `validateResponse` will throw for every alert item and the alerts query will always fail at runtime. Align the schema with the real response shape, e.g. include `studentId/studentName/studentNo/className/type/count/lastDate`.","suggestion_code":".object({\n studentId: z.number(),\n studentName: z.string(),\n studentNo: z.string().nullable().optional(),\n className: z.string(),\n type: z.string(),\n count: z.number(),\n lastDate: z.string(),\n })","existing_code":".object({ id: z.number(), type: z.string(), message: z.string() })"}
{"path":"apps/admin/src/api/schemas/attendance.ts","start_line":44,"end_line":44,"category":"documentation","severity":"low","content":"This comment (\"学生档案聚合\" / student profile aggregation) is misplaced: it sits before the attendance class-option/alerts/periods/schedule-option schemas, which have nothing to do with student profile aggregation. It looks like a leftover from a removed schema and is misleading — remove it or move it to the correct schema.","suggestion_code":null,"existing_code":"/** 学生档案聚合 */"}
{"path":"apps/admin/src/auth/permission-navigation.ts","start_line":60,"end_line":63,"category":"security","severity":"medium","content":"Fail-open behavior: `getRequiredPermission` returns null for any pathname not registered in `PERMISSION_PAGES` (including subpaths not covered by the `matches` regexes, and paths with trailing slashes), and `canAccessPath` treats `required === null` as accessible. If this function is used by the route guard (not just menu rendering), any route that is forgotten to be registered — e.g. `/students/new`, `/students/123/edit`, or any future detail page — silently bypasses the permission check. Consider failing closed (`required === null` → return false) or making unknown paths require explicit registration, so new routes can't accidentally become public.","suggestion_code":null,"existing_code":"export function canAccessPath(pathname: string, permissions: readonly string[]): boolean {\n const required = getRequiredPermission(pathname);\n return required === null || permissions.includes(required);\n}"}
{"path":"apps/admin/src/auth/permission-navigation.ts","start_line":17,"end_line":21,"category":"bug","severity":"medium","content":"Subpath coverage is narrow and inconsistent across entries: `/students` only matches `/students/{digits}/profile`, while `/classes` and `/exams` match `/{digits}`. Any other detail route (create/edit pages, non-numeric ids, trailing slashes) falls through to `getRequiredPermission` → null and is therefore allowed (see the fail-open behavior in `canAccessPath`). The three `matches` implementations are also duplicated; extract a shared helper (e.g. `baseOrDetail(path, regex)`) and make sure every real route under these prefixes is either matched or explicitly excluded.","suggestion_code":null,"existing_code":" {\n path: '/students',\n permission: 'student:view',\n matches: (p) => p === '/students' || /^\\/students\\/\\d+\\/profile$/.test(p),\n },"}
{"path":"apps/admin/src/auth/menu-policy.ts","start_line":157,"end_line":163,"category":"bug","severity":"medium","content":"The accommodation domain inference doesn't cover all permissions that back menu entries in the accommodation section. `bill:view` (账单管理), `deposit:view` (押金管理), and a `room:view`-only grant (房间管理) never add the 'accommodation' domain, so a user granted only these permissions will never see the 住宿运营 section and those menus become unreachable. For consistency with the academic/classroom/system rules, add the missing triggers, e.g. `|| permissions.includes('bill:view') || permissions.includes('deposit:view')` (and decide whether `room:view` alone should count).","suggestion_code":" if (\n (permissions.includes('room:view') &&\n (permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||\n permissions.includes('wallet:view') ||\n permissions.includes('bill:view') ||\n permissions.includes('deposit:view')\n ) {\n normalized.add('accommodation');\n }","existing_code":" if (\n (permissions.includes('room:view') &&\n (permissions.includes('occupancy:view') || permissions.includes('expense:view'))) ||\n permissions.includes('wallet:view')\n ) {\n normalized.add('accommodation');\n }"}
{"path":"apps/admin/src/auth/menu-policy.ts","start_line":170,"end_line":173,"category":"bug","severity":"medium","content":"This branch contradicts the comment above it (\"业务域按能力累加\" — domains accumulate by capability). If a user has an administrative domain (e.g. academic/system) AND holds `teacher-workspace:view`, the 'teacher' domain is deliberately withheld, so the 教学工作 section (今日教学) becomes inaccessible to them even though they have the permission. Either remove the `!hasAdministrativeDomain` guard so teacher capability accumulates like the other domains, or document why teaching menus are intentionally exclusive to pure-teacher users.","suggestion_code":" if (permissions.includes('teacher-workspace:view')) {\n normalized.add('teacher');\n }","existing_code":" const hasAdministrativeDomain = [...normalized].some((role) => role !== 'teacher');\n if (!hasAdministrativeDomain && permissions.includes('teacher-workspace:view')) {\n normalized.add('teacher');\n }"}
{"path":"apps/admin/src/auth/menu-policy.ts","start_line":27,"end_line":27,"category":"security","severity":"medium","content":"Mapping 财务 (finance) to the 'accommodation' domain grants finance users the entire 住宿运营 section, including 房间管理 (room:view), 入住管理 (occupancy:view) and 住宿总览 — menus unrelated to billing. If the finance role is only meant to handle 费用/账单/余额/押金, this grants over-broad menu access. Please confirm this is intentional; otherwise introduce a dedicated finance domain (or role) and restrict which entries it can see.","suggestion_code":null,"existing_code":" 财务: 'accommodation',"}
{"path":"apps/admin/src/auth/menu-policy.ts","start_line":213,"end_line":213,"category":"maintainability","severity":"low","content":"Spreading `...section` and then casting with `as AppMenuItem` bypasses type checking: at runtime the object still carries `roles` (set to undefined) and the un-stripped `children` type, hiding real type errors. Prefer constructing the object explicitly without the cast, e.g. `{ key, label, icon, children }` or a type that allows `roles?: undefined`.","suggestion_code":" sections.push({ key: section.key, label: section.label, icon: section.icon, children });","existing_code":" sections.push({ ...section, children, roles: undefined } as AppMenuItem);"}
{"path":"apps/admin/src/auth/menu-policy.ts","start_line":224,"end_line":224,"category":"bug","severity":"low","content":"`collectMenuPaths` never returns an item's own key when it has children, so any future menu item that is both navigable and a parent would be silently omitted from the path list (and from `findRoleAwareLandingPath`). This is only correct today because all section keys are non-route group keys; consider collecting the parent key as well (e.g. `item.children ? [item.key, ...collectMenuPaths(item.children)] : [item.key]`) or documenting that section keys are intentionally excluded.","suggestion_code":null,"existing_code":" return items.flatMap((item) => (item.children ? collectMenuPaths(item.children) : [item.key]));"}
{"path":"apps/admin/src/components/AiChat/message-mappers.ts","start_line":31,"end_line":32,"category":"bug","severity":"medium","content":"historyForms/historyReviews cast metadata directly into AiFormSchema/AiReviewSchema without validating shape, while historyCharts/historyArtifacts validate each item is an object with a string `id`. Malformed metadata (e.g. missing `id`, or an array stored in a2uiForm, which the Array.isArray guard silently drops) will either pass through invalid schemas or be silently discarded. Apply the same guard used in historyCharts (object check + string `id` check, and handle array metadata) so the four helpers behave consistently and the render layer never receives an invalid schema.","suggestion_code":null,"existing_code":" if (!a2uiForm || typeof a2uiForm !== 'object' || Array.isArray(a2uiForm)) return undefined;\n return [a2uiForm as AiFormSchema];"}
{"path":"apps/admin/src/components/AiChat/message-mappers.ts","start_line":43,"end_line":43,"category":"maintainability","severity":"low","content":"The four history* helpers duplicate the same extraction pattern (read a metadata key, guard against non-object/array, wrap in array, optionally filter invalid items). Consider extracting a small generic helper (e.g. metadataList<T>(record, key, validator)) to eliminate the duplication and keep validation rules consistent in one place.","suggestion_code":null,"existing_code":"function historyCharts(record: AiMessageRecord): AiChartSchema[] | undefined {"}
{"path":"apps/admin/src/components/AiChat/message-mappers.ts","start_line":18,"end_line":18,"category":"bug","severity":"low","content":"A completed user message loaded from history is mapped to status 'local' (via `record.role === 'user' ? 'local' : 'success'`). In the X chat SDK, 'local' typically denotes an optimistic message that has not yet been confirmed by the server; a history record with status 'completed' is server-persisted, so marking it 'local' may cause the UI to treat it as a local echo (e.g. show a sending indicator or alter available actions like retry/edit). Verify how the message list consumes the status — mapping completed user messages to 'success' may be more accurate.","suggestion_code":null,"existing_code":" return record.role === 'user' ? 'local' : 'success';"}
{"path":"apps/admin/src/components/AiChat/types.ts","start_line":233,"end_line":237,"category":"bug","severity":"medium","content":"`data` is declared non-nullable even though `success` may be false. All callers in api.ts do `(await api.get<AiApiResponse<T>>(...)).data` without checking `success`, so when the backend returns `{ success: false }` (error contract without payload) the runtime value is `undefined` while TypeScript believes it is always `T` — a silent undefined-access risk. Make `data: T | null` (or have the API layer throw on `success: false`).","suggestion_code":"export interface AiApiResponse<T> {\n success: boolean;\n data: T | null;\n message?: string;\n}","existing_code":"export interface AiApiResponse<T> {\n success: boolean;\n data: T;\n message?: string;\n}"}
{"path":"apps/admin/src/components/AiChat/types.ts","start_line":167,"end_line":171,"category":"maintainability","severity":"low","content":"`AiChatMessage.reasoningContent` is required and non-nullable, but the persisted record (`AiMessageRecord.reasoningContent: string | null` and the server entity `ai-message.entity.ts`) treats it as nullable, and the mapper (`message-mappers.ts`) has to do `record.reasoningContent || ''` to bridge them. For user-role messages reasoning content semantically doesn't exist, so every construction site is forced to fabricate `''`. Align the type as `string | null` to match the record/server contract and avoid the lossy `|| ''` conversion.","suggestion_code":" reasoningContent: string | null;","existing_code":"export interface AiChatMessage {\n id?: number | string;\n role: AiMessageRole;\n content: string;\n reasoningContent: string;"}
{"path":"apps/admin/src/components/AiChat/types.ts","start_line":138,"end_line":144,"category":"maintainability","severity":"low","content":"`AiToolRunStatus` contains both `'error'` and `'failed'`, which are semantically the same failure state. The codebase already has to translate between them (`message-mappers.ts` and `sseReducer.ts` both map tool status `'error'` → `'failed'`), and `AiMessageRecord.status`/`AiChatMessageStatus` use different sets (`failed` vs `error`). Keeping overlapping states in one union forces mapping code and risks unhandled cases; consolidate to a single failure value.","suggestion_code":null,"existing_code":"export type AiToolRunStatus =\n | 'running'\n | 'success'\n | 'error'\n | 'failed'\n | 'denied'\n | 'not_found';"}
{"path":"apps/admin/src/components/AiChat/api.ts","start_line":24,"end_line":24,"category":"maintainability","severity":"medium","content":"Inconsistent return contract: unlike `deleteAllConversations`/`deleteMessage` (which unwrap `.data`), `deleteConversation` returns the raw response body and types it as `void`. Since the axios response interceptor in `src/api/index.ts` already unwraps `res.data`, this method actually resolves to the full body (e.g. `{ success: true, data: ... }`), so the declared `void` type is misleading and any future caller expecting the unwrapped payload (like sibling methods) would get the wrong shape. `deleteAttachment` below has the same problem. Suggest unwrapping `.data` for consistency: `async (id: number) => (await api.delete<AiApiResponse<{ deleted: boolean }>>(`${basePath}/${id}`)).data`.","suggestion_code":null,"existing_code":" deleteConversation: (id: number) => api.delete<void>(`${basePath}/${id}`),"}
{"path":"apps/admin/src/components/AiChat/api.ts","start_line":89,"end_line":91,"category":"maintainability","severity":"low","content":"The `/api` prefix is hardcoded here but is already defined as the axios `baseURL` in `src/api/index.ts`. If the API base URL is ever changed (e.g. to `/api/v2`), the SSE stream URL will silently diverge from all REST calls made via the `api` instance. Consider deriving the prefix from a shared constant (or the api instance's `baseURL`) instead of duplicating the literal string.","suggestion_code":null,"existing_code":"export function conversationStreamUrl(id: number): string {\n return `/api${basePath}/${id}/stream`;\n}"}
{"path":"apps/admin/src/components/AiChat/api.ts","start_line":71,"end_line":71,"category":"maintainability","severity":"low","content":"`page: 1` and `limit: 100` are hardcoded business numbers embedded inline. Consider extracting them to named constants (e.g. `const MESSAGE_PAGE_SIZE = 100`) used both for the initial request and the subsequent page requests, so the page size stays consistent and is self-documenting.","suggestion_code":null,"existing_code":" params: { page: 1, limit: 100 },"}
{"path":"apps/admin/src/components/AiChat/reviewSection.ts","start_line":32,"end_line":33,"category":"bug","severity":"medium","content":"`sectionType` silently defaults to `'students'` for any section whose `type` is undefined and whose key doesn't match a known type/prefix. Unknown or newly-added section keys will be silently lumped into the 'students' group, which then skews `groupSections`/`groupStatus`/`dependencyHint` results (e.g., unknown sections counted as student data and blocking 'transfers' dependency resolution). Prefer an explicit handling for unmatched keys (throw/log a warning or return null and let the caller decide) instead of a silent fallback.","suggestion_code":" const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));\n if (prefix) return prefix;\n throw new Error(`Unknown AI review section type for key: ${section.key}`);","existing_code":" const prefix = SECTION_ORDER.find((type) => section.key.startsWith(`${type}_`));\n return prefix ?? 'students';"}
{"path":"apps/admin/src/components/AiChat/reviewSection.ts","start_line":94,"end_line":96,"category":"bug","severity":"low","content":"`groupStatus` ignores the 'skipped' status entirely: a group whose sections are all skipped falls through to `'partial'` (部分完成), which is misleading since there is nothing actionable left in that group. Consider returning a 'skipped'-aware result (or at least 'pending') when `items.every(...)` is 'skipped', so the UI doesn't show '部分完成' for a fully-skipped group.","suggestion_code":" if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';\n if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';\n if (items.every((item) => sectionStatus(item) === 'skipped')) return 'pending';\n return 'partial';","existing_code":" if (items.some((item) => sectionStatus(item) === 'failed')) return 'failed';\n if (items.every((item) => sectionStatus(item) === 'submitted')) return 'submitted';\n return 'partial';"}
{"path":"apps/admin/src/components/AiChat/reviewSection.ts","start_line":20,"end_line":31,"category":"maintainability","severity":"low","content":"The same four-way union check on `section.type` is duplicated for `section.key` below. Extract a small type guard (e.g., `const isSectionType = (v: unknown): v is AiReviewSectionType => v === 'students' || v === 'rooms' || v === 'transfers' || v === 'checkins';`) and reuse it in both branches to avoid the duplicated literals drifting apart when a new section type is added.","suggestion_code":" const isSectionType = (value: unknown): value is AiReviewSectionType =>\n value === 'students' || value === 'rooms' || value === 'transfers' || value === 'checkins';\n if (isSectionType(section.type)) {\n return section.type;\n }\n if (isSectionType(section.key)) {\n return section.key;\n }","existing_code":" if (\n section.type === 'students' ||\n section.type === 'rooms' ||\n section.type === 'transfers' ||\n section.type === 'checkins'\n ) {\n return section.type;\n }\n const key = section.key as AiReviewSectionType;\n if (key === 'students' || key === 'rooms' || key === 'transfers' || key === 'checkins') {\n return key;\n }"}
{"path":"apps/admin/src/components/AiChat/sseReducer.ts","start_line":92,"end_line":94,"category":"bug","severity":"medium","content":"In the update branch, `{ ...item, ...next }` overwrites existing fields even when `next` explicitly carries `undefined`. Since `next` always sets `argumentsSummary: undefined` for non-running events and `resultSummary: undefined` for running events, a run started with `tool.started` (which captured `argumentsSummary`) will have its arguments summary wiped to `undefined` when `tool.completed`/`tool.failed` arrives — the completed payload rarely re-sends the original arguments. Result: the run loses its argument summary in the UI. Only override fields that actually have a value, or preserve the previous values.","suggestion_code":" const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);\n if (index === -1) return [...toolRuns, next];\n return toolRuns.map((item, itemIndex) =>\n itemIndex === index\n ? {\n ...item,\n ...next,\n argumentsSummary: next.argumentsSummary ?? item.argumentsSummary,\n resultSummary: next.resultSummary ?? item.resultSummary,\n }\n : item,\n );","existing_code":" const index = toolRuns.findIndex((item) => item.toolCallId === toolCallId);\n if (index === -1) return [...toolRuns, next];\n return toolRuns.map((item, itemIndex) => (itemIndex === index ? { ...item, ...next } : item));"}
{"path":"apps/admin/src/components/AiChat/sseReducer.ts","start_line":152,"end_line":157,"category":"bug","severity":"medium","content":"`message.reasoningContent +=` / `message.content +=` will produce a literal \"undefined\"/\"null\" prefix when the field is missing on `originMessage` at runtime (e.g. an assistant message loaded from a persisted record where `reasoningContent` can be `null`). Because `{ ...originMessage }` only copies existing keys, the accumulator may not be initialized. Use a null-safe accumulator instead.","suggestion_code":" } else if (event === 'reasoning.delta') {\n message.retrying = null;\n message.reasoningContent = (message.reasoningContent ?? '') + (payload.delta ?? payload.reasoningContent ?? '');\n } else if (event === 'content.delta') {\n message.retrying = null;\n message.content = (message.content ?? '') + (payload.delta ?? payload.content ?? '');","existing_code":" } else if (event === 'reasoning.delta') {\n message.retrying = null;\n message.reasoningContent += payload.delta ?? payload.reasoningContent ?? '';\n } else if (event === 'content.delta') {\n message.retrying = null;\n message.content += payload.delta ?? payload.content ?? '';"}
{"path":"apps/admin/src/components/AiChat/sseReducer.ts","start_line":81,"end_line":81,"category":"bug","severity":"medium","content":"The fallback `toolCallId` is derived from `toolRuns.length`, which changes after every upsert. If the SSE stream omits `toolCallId` for the same logical run across events (the very case this fallback exists for), `tool.started` creates \"tool-0\" but the matching `tool.completed`/`tool.failed` generates \"tool-1\" and appends a duplicate run instead of updating the original — leaving a permanently \"running\" entry. Prefer a stable key (e.g. toolName+messageId, or require `toolCallId` from the server) for the fallback.","suggestion_code":null,"existing_code":" const toolCallId = payload.toolCallId || `${payload.toolName || 'tool'}-${toolRuns.length}`;"}
{"path":"apps/admin/src/components/AiChat/sseReducer.ts","start_line":133,"end_line":134,"category":"bug","severity":"low","content":"`message.metadata = nested.metadata ?? message.metadata` replaces the entire metadata object. Any state accumulated during streaming — e.g. `a2uiImportWizard` set by the `ui.import_wizard` event via `{ ...message.metadata, a2uiImportWizard }` — will be silently dropped if the `message.completed`/`message.created` snapshot from the server doesn't include it. Merge instead of replacing to avoid losing transient metadata.","suggestion_code":" message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;\n message.metadata = { ...(message.metadata ?? {}), ...(nested.metadata ?? {}) };","existing_code":" message.replyToMessageId = nested.replyToMessageId ?? message.replyToMessageId;\n message.metadata = nested.metadata ?? message.metadata;"}
{"path":"apps/admin/src/components/AiChat/uiArtifacts.ts","start_line":13,"end_line":13,"category":"style","severity":"low","content":"Nested ternary (prohibited by project review rules). `incoming ? [incoming] : []` is nested inside the false branch of `Array.isArray(incoming) ? ... : ...`, which hurts readability. Prefer explicit branches.","suggestion_code":" let items: T[];\n if (Array.isArray(incoming)) {\n items = incoming;\n } else if (incoming) {\n items = [incoming];\n } else {\n items = [];\n }","existing_code":" const items = Array.isArray(incoming) ? incoming : incoming ? [incoming] : [];"}
{"path":"apps/admin/src/components/AiChat/uiArtifacts.ts","start_line":39,"end_line":40,"category":"bug","severity":"medium","content":"`mergeArtifactIntoMessage` mutates the passed-in `message` in place (`message.uiArtifacts = ...`) and then returns the same reference. In `useAiChatMessageActions.tsx` it is invoked inside a React state updater (`setMessage(messageId, (info) => ({ message: mergeArtifactIntoMessage(info.message, artifact) }))`), which means: 1) the current state object is mutated directly, violating React immutability rules; 2) since the same `message` reference is returned, any component memoized on the message object reference will not re-render and the newly arrived artifact won't be displayed. Make the function immutable so it returns a new message object.","suggestion_code":" return {\n ...message,\n uiArtifacts: mergeById<AiArtifactSchema>(message.uiArtifacts, artifact),\n };","existing_code":" message.uiArtifacts = mergeById<AiArtifactSchema>(message.uiArtifacts, artifact);\n return message;"}
{"path":"apps/admin/src/components/AiChat/uiArtifacts.ts","start_line":51,"end_line":51,"category":"maintainability","severity":"low","content":"Unsound type predicate: `payload is AiFormSchema` (and the analogous predicates for AiReviewSchema/AiChartSchema) only checks truthiness and `typeof === 'object'`, yet asserts a full typed schema. Malformed payloads from the server (e.g., missing `fields`/`sections`/`rows`) will pass this guard and later crash rendering at runtime with no compile-time protection. At minimum, verify a distinguishing required field, or validate/normalize the payload before trusting the cast.","suggestion_code":" .filter((payload): payload is AiFormSchema =>\n Boolean(payload) && typeof payload === 'object' && Array.isArray(payload.fields),\n );","existing_code":" .filter((payload): payload is AiFormSchema => Boolean(payload) && typeof payload === 'object');"}
{"path":"apps/admin/src/components/JinshujuMatchModal.types.ts","start_line":1,"end_line":11,"category":"maintainability","severity":"low","content":"The `suggestedStudent` shape in `JinshujuEntryRow` is structurally identical to `StudentOption` (id/name/phone/studentNo). Consider reusing `StudentOption` here to avoid duplicating the type definition — e.g. `suggestedStudent: StudentOption | null;` — so future field changes only need to be made in one place.","suggestion_code":null,"existing_code":"export interface JinshujuEntryRow {\n serialNumber: number;\n name: string;\n phone: string | null;\n suggestedStudent: {\n id: number;\n name: string;\n phone: string | null;\n studentNo: string | null;\n } | null;\n}"}
{"path":"apps/admin/src/components/ImportWizard/types.ts","start_line":34,"end_line":35,"category":"maintainability","severity":"low","content":"The status union `'preparing' | 'ready' | 'committing' | 'committed' | 'failed' | 'expired'` is duplicated verbatim in `ImportReceipt.runStatus`, while `ImportStepDetail.status` repeats a similar-but-different union (`'pending' ... 'skipped'`). This duplication risks silent drift between the types (e.g. steps support `'skipped'` while run/receipt statuses do not, and only run statuses have `'expired'`). Suggest extracting named union types (e.g. `type ImportRunStatus = ...; type ImportStepStatus = ...`) and referencing them from all interfaces so status sets stay consistent.","suggestion_code":null,"existing_code":" status: 'preparing' | 'ready' | 'committing' | 'committed' | 'failed' | 'expired';\n currentStepKey: ImportStepKey | null;"}
{"path":"apps/admin/src/components/AiChat/provider.ts","start_line":83,"end_line":87,"category":"maintainability","severity":"low","content":"The login redirect path `/login` and the `login_expired_hint` sessionStorage key are hardcoded business constants that are coupled with the login page (per the code comment). If the route changes, this silently breaks the expired-session hint flow. Suggest extracting them into a shared constant (e.g., a routes/constants module) used by both this file and the login page.","suggestion_code":null,"existing_code":" // 提示由登录页读取展示:直接弹 toast 会被跳转销毁\n sessionStorage.setItem('login_expired_hint', '1');\n useUserStore.getState().logout();\n usePermissionStore.getState().clearPermissions();\n window.location.href = '/login';"}
{"path":"apps/admin/src/components/AiChat/provider.ts","start_line":130,"end_line":130,"category":"bug","severity":"low","content":"`crypto.randomUUID()` is undefined (and would throw a TypeError) in non-secure contexts (HTTP on non-localhost hosts) and in older browsers. Since `transformParams` runs before every request, a thrown error here would break the whole send flow whenever the caller doesn't supply `clientRequestId`. Add a safe fallback, e.g. `requestParams.clientRequestId || globalThis.crypto?.randomUUID?.() || \\`${Date.now()}-${Math.random().toString(36).slice(2)}\\``.","suggestion_code":null,"existing_code":" clientRequestId: requestParams.clientRequestId || crypto.randomUUID(),"}
{"path":"apps/admin/src/components/AiChat/provider.ts","start_line":28,"end_line":28,"category":"maintainability","severity":"medium","content":"Endpoint rewriting relies on fragile regexes with different anchors for different flows: regenerate/edit strip a trailing `/stream`, while form/review require the URL to match `/conversations/\\d+/stream`. If the base stream URL format ever changes (e.g., adds a prefix, query params, or non-numeric ids), `String.replace` silently no-ops and the request is POSTed to the wrong endpoint with a rewritten body, producing hard-to-trace failures. Consider deriving the base path once (e.g., strip `/stream` via a shared helper) and building each sub-path (`/messages/{id}/regenerate/stream`, `/forms/{formId}/submit/stream`, ...) from it, so all four branches stay consistent.","suggestion_code":null,"existing_code":" requestInput = `${String(input).replace(/\\/stream$/, '')}/messages/${body.regenerateMessageId}/regenerate/stream`;"}
{"path":"apps/admin/src/components/AiChat/useSubmissionState.ts","start_line":77,"end_line":82,"category":"bug","severity":"high","content":"This block mutates refs (commandsRef / idRef) during the render phase to detect a surfaceId change. This is a render-phase side effect: it violates React's rules and is unsafe under StrictMode double-rendering/concurrent rendering (an aborted render can leave the refs mutated while the committed output still belongs to the old surface). It also leaves the returned `commands` state out of sync: the refs are cleared but the `commands` state array is not reset, so consumers keep receiving the previous surface's commands until the next `pushCommands` call. Move the reset into an effect (and clear the state in sync), e.g.:\n\n```ts\nconst surfaceKey = surfaceId;\nuseEffect(() => {\n if (idRef.current !== surfaceKey) {\n commandsRef.current = [];\n idRef.current = surfaceKey;\n setCommands([]);\n }\n}, [surfaceKey]);\n```\n\nAlternatively, remount the consumer component per surface with `key={surfaceId}`.","suggestion_code":null,"existing_code":" const surfaceKey = surfaceId;\n if (idRef.current !== surfaceKey) {\n // 组件复用到新 surface 时,清空历史命令重新初始化\n commandsRef.current = [];\n idRef.current = surfaceKey;\n }"}
{"path":"apps/admin/src/components/AiChat/useSubmissionState.ts","start_line":20,"end_line":22,"category":"bug","severity":"medium","content":"`run` does not reset `submitted` to false when a new submission starts. If the same hook instance is reused for a second submission (e.g., resubmit/edit after a successful submit) without an explicit `reset()` call, `submitted` stays `true` while the new request is in-flight, so consumers can observe both `submitting === true` and `submitted === true` simultaneously, producing inconsistent UI state. Reset it at the start of `run`.","suggestion_code":" submittingRef.current = true;\n setSubmitting(true);\n setSubmitted(false);\n setError(null);","existing_code":" submittingRef.current = true;\n setSubmitting(true);\n setError(null);"}
{"path":"apps/admin/src/components/AiChat/welcomeCopy.ts","start_line":39,"end_line":41,"category":"maintainability","severity":"medium","content":"Example order contradicts the priority documented in this file. welcomeDescription resolves multi-domain users as 教师 > 住宿运营 > 教务 > 教室运营 > 系统/超管, but workflowPromptExamples pushes examples in the order academic → accommodation → classroom → teacher → system/super. Since getRoleDomains accumulates multiple domains for a single user (e.g., academic + accommodation + system via permissions), the suggested prompts will be shown in a different order than the welcome message priority implies. Recommend aligning the push order with the documented priority (teacher → accommodation → academic → classroom → system/super).","suggestion_code":null,"existing_code":" if (domains.has('academic')) {\n examples.push(\n { label: '帮我从 Excel 导入学生并完成分班', description: '教学闭环' },"}
{"path":"apps/admin/src/components/AiChat/welcomeCopy.ts","start_line":7,"end_line":8,"category":"maintainability","severity":"low","content":"The domain keys 'teacher'/'accommodation'/'academic'/'classroom'/'system'/'super' are hardcoded here and duplicated in auth/menu-policy.ts (ROLE_ALIASES and getRoleDomains). These two files are tightly coupled through string literals: if a domain identifier is ever renamed, these `.has()` checks fail silently and users silently get the fallback welcome message / empty examples instead of a compile error. Consider exporting shared domain constants from menu-policy and importing them here.","suggestion_code":null,"existing_code":"export function welcomeDescription(roles: readonly string[], permissions: readonly string[]): string {\n const domains = getRoleDomains(roles, permissions);"}
{"path":"apps/admin/src/components/StudentProfileContent/shared.ts","start_line":194,"end_line":194,"category":"maintainability","severity":"low","content":"formatFileSize stops at MB, so files ≥1GB (e.g., videos, scan archives) render as unwieldy numbers like \"2048.0 MB\" instead of \"2.0 GB\". Consider adding a GB branch (and optionally TB) for large attachments.","suggestion_code":" if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;","existing_code":" return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;"}
{"path":"apps/admin/src/components/StudentProfileContent/shared.ts","start_line":116,"end_line":116,"category":"maintainability","severity":"low","content":"Color values are inconsistent across the status maps: preset Ant Design Tag colors (green/orange/red/blue/default) are mixed with a raw hex value (#999). Ant Design renders preset colors with theme-derived backgrounds while non-preset values are treated as custom colors, producing visually inconsistent tags. Use a single convention (all preset names or all explicit colors).","suggestion_code":null,"existing_code":" withdrawn: { text: '放弃', color: '#999' },"}
{"path":"apps/admin/src/hooks/usePermission.ts","start_line":17,"end_line":21,"category":"bug","severity":"low","content":"Edge case: `hasAllPermissions()` invoked with zero arguments returns `permissionsReady` (i.e., `true` whenever permissions are ready) due to the vacuous truth of `codes.every(...)` on an empty array. This silently bypasses any permission check and could grant access accidentally if a caller forgets to pass codes. Consider returning `false` when `codes.length === 0` (fail-closed), e.g. `permissionsReady && codes.length > 0 && codes.every(...)`. Note `hasAnyPermission()` with zero args already behaves fail-closed (returns `false`), so this is inconsistent as well.","suggestion_code":" const hasAllPermissions = useCallback(\n (...codes: string[]): boolean =>\n permissionsReady && codes.length > 0 && codes.every((code) => permissions.includes(code)),\n [permissions, permissionsReady],\n );","existing_code":" const hasAllPermissions = useCallback(\n (...codes: string[]): boolean =>\n permissionsReady && codes.every((code) => permissions.includes(code)),\n [permissions, permissionsReady],\n );"}
{"path":"apps/admin/src/hooks/usePageVisible.ts","start_line":29,"end_line":30,"category":"bug","severity":"medium","content":"Writing to a ref during render (`keyRef.current = queryKey`) is a render-time side effect. React explicitly discourages reading/writing `ref.current` during render (except lazy init): in concurrent rendering the render may be discarded/replayed, so the ref can end up inconsistent with the committed render, and StrictMode double-renders can make this visible. Since the goal here is just to keep the latest queryKey, update it in a `useEffect` declared before the refetch effect (effects run in declaration order, so the key will be fresh when the refetch effect runs).","suggestion_code":" const keyRef = useRef(queryKey);\n useEffect(() => {\n keyRef.current = queryKey;\n }, [queryKey]);","existing_code":" const keyRef = useRef(queryKey);\n keyRef.current = queryKey;"}
{"path":"apps/admin/src/hooks/usePageVisible.ts","start_line":32,"end_line":36,"category":"performance","severity":"low","content":"On initial mount of the currently-active page, `visible` is already `true`, so this effect fires immediately and refetches the query — duplicating the initial fetch that `useQuery` already performed on mount. The hook's stated purpose is to refresh data when the page is *switched back to* (hidden → visible), not on first load. Consider tracking the previous visibility so the refetch only triggers on a hidden → visible transition.","suggestion_code":" const prevVisibleRef = useRef(visible);\n useEffect(() => {\n const becameVisible = visible && !prevVisibleRef.current;\n prevVisibleRef.current = visible;\n if (becameVisible && keyRef.current) {\n void queryClient.refetchQueries({ queryKey: keyRef.current });\n }\n }, [visible, queryClient]);","existing_code":" useEffect(() => {\n if (visible && keyRef.current) {\n void queryClient.refetchQueries({ queryKey: keyRef.current });\n }\n }, [visible, queryClient]);"}
{"path":"apps/admin/src/hooks/useSubmitShortcut.ts","start_line":18,"end_line":18,"category":"performance","severity":"low","content":"The effect re-registers the window keydown listener whenever the `onSubmit` identity changes. If the caller passes an inline arrow function (very common at call sites), the listener is removed and re-added on every render while `active` is true, which is needless churn. Consider storing the latest callback in a ref so the listener is only (re)bound when `active` changes.","suggestion_code":" const onSubmitRef = useRef(onSubmit);\n useEffect(() => {\n onSubmitRef.current = onSubmit;\n });\n\n useEffect(() => {\n if (!active) return;\n const handleKeyDown = (event: KeyboardEvent) => {\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {\n event.preventDefault();\n onSubmitRef.current();\n }\n };\n window.addEventListener('keydown', handleKeyDown);\n return () => window.removeEventListener('keydown', handleKeyDown);\n }, [active]);","existing_code":" }, [active, onSubmit]);"}
{"path":"apps/admin/src/hooks/useSubmitShortcut.ts","start_line":11,"end_line":11,"category":"bug","severity":"low","content":"In Chinese/IME input contexts, a keydown event fired while composing text (e.g. confirming an IME candidate with Enter) will still match this condition and can trigger an unintended submit. Guard against IME composition with `event.isComposing` (or `event.keyCode === 229`) before invoking `onSubmit`.","suggestion_code":" if (event.isComposing) return;\n if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {","existing_code":" if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {"}
{"path":"apps/admin/src/hooks/useDirtyGuard.ts","start_line":19,"end_line":19,"category":"bug","severity":"medium","content":"`pristineRef` starts as `null`. If `snapshot()` has not been called yet (e.g., a caller forgets to invoke it, or `isDirty()`/`confirmClose()` is triggered before snapshot), `equal(form.getFieldsValue(), null)` always returns `false`, so `isDirty()` incorrectly reports `true` and a spurious confirmation dialog appears even for an untouched form. Make the contract defensive, e.g., treat a null baseline as not dirty, or initialize the ref with `form.getFieldsValue()` at mount.","suggestion_code":"const pristineRef = useRef<unknown>(form.getFieldsValue());","existing_code":"const pristineRef = useRef<unknown>(null);"}
{"path":"apps/admin/src/hooks/useDirtyGuard.ts","start_line":28,"end_line":28,"category":"bug","severity":"low","content":"`form.getFieldsValue()` only returns values for fields that are currently registered/mounted. If a field is conditionally rendered and unmounts between `snapshot()` and the dirty check (or re-mounts with a reset value), the compared value sets will differ and produce false-positive/false-negative dirty results. Consider using `form.getFieldsValue(true)` in both `snapshot()` and `isDirty()` so the comparison is stable regardless of whether fields are currently mounted.","suggestion_code":"return !equal(form.getFieldsValue(true), pristineRef.current);","existing_code":"return !equal(form.getFieldsValue(), pristineRef.current);"}
{"path":"apps/admin/src/hooks/useDownload.ts","start_line":20,"end_line":21,"category":"maintainability","severity":"low","content":"`downloading` state and `busyRef` are two sources of truth for the same busy flag, and they must be manually kept in sync (assigned in three places). If they ever drift (e.g., an early-return path or a future edit), the button's `loading` state will no longer match the guard behavior. Consider consolidating to a single source of truth — e.g., keep `busyRef` for the synchronous guard and update both via a small helper, or use `useReducer`/`useSyncExternalStore` so the render state is derived from one flag.","suggestion_code":null,"existing_code":" const [downloading, setDownloading] = useState(false);\n const busyRef = useRef(false);"}
{"path":"apps/admin/src/hooks/useDownload.ts","start_line":32,"end_line":34,"category":"bug","severity":"low","content":"`message.error(...)` can itself throw: `app-message`'s `requireMessageApi()` throws if the Ant Design message API has not been initialized yet. Because this call sits inside the `catch` block of an async function, that throw replaces the original download error, the promise returned by `run` rejects, and the failure propagates to the caller as an unhandled rejection with no user feedback. Consider wrapping the toast calls in a defensive `try/catch`, or ensure `bindMessageApi` is guaranteed to run before this hook is used.","suggestion_code":null,"existing_code":"message.error(\n options?.errorMsg ?? (error instanceof Error ? error.message : '下载失败,请重试'),\n );"}
{"path":"apps/admin/src/hooks/useDownload.ts","start_line":35,"end_line":38,"category":"bug","severity":"low","content":"If the owning component unmounts while `downloadBlob` is still in flight, the `finally` block will call `setDownloading(false)` on an unmounted component, and the in-flight `fetch` cannot be aborted. Consider tracking a mounted flag (or using an `AbortController` passed through to `downloadBlob`) so the async work can be cancelled and state updates skipped after unmount.","suggestion_code":null,"existing_code":" } finally {\n busyRef.current = false;\n setDownloading(false);\n }"}
{"path":"apps/admin/src/hooks/useApiQuery.ts","start_line":10,"end_line":10,"category":"maintainability","severity":"medium","content":"Type-safety gap: `schema` is typed as `z.ZodType<unknown>` with no link to `T`, so this wrapper (whose stated purpose is type safety) doesn't actually guarantee the validated shape matches `T`. `validateResponse<T>` simply casts `result.data as T` (see utils/validate.ts), so a caller can pass a schema whose output type differs from `T` and it compiles fine — the mismatch only surfaces at runtime. Consider constraining the schema to the generic, e.g. `schema: z.ZodType<T, z.ZodTypeDef, unknown>` (and update `validateResponse`'s signature accordingly), so mismatched schema/type pairs fail at compile time.","suggestion_code":" schema: z.ZodType<T, z.ZodTypeDef, unknown>;","existing_code":" schema: z.ZodType<unknown>;"}
{"path":"apps/admin/src/hooks/useApiQuery.ts","start_line":28,"end_line":28,"category":"performance","severity":"low","content":"The wrapped `queryFn` is recreated as a new closure on every render and it drops the QueryFunctionContext (specifically the AbortSignal). Consequences: (1) the new function identity invalidates any option memoization every render (minor cost; React Query won't refetch solely on identity change), and (2) callers have no way to cancel in-flight requests on unmount/refetch since the signal is never forwarded. If request cancellation matters, widen `queryFn` to accept the context and forward it: `queryFn: (ctx) => ... queryFn(ctx)` and pass `signal` down to the underlying API call.","suggestion_code":null,"existing_code":" queryFn: async () => validateResponse<T>(schema, await queryFn()),"}
{"path":"apps/admin/src/pages/Attendance/types.ts","start_line":15,"end_line":16,"category":"maintainability","severity":"low","content":"`status` is typed as a plain `string`, which allows invalid values to compile silently. Since attendance statuses come from a fixed domain set (e.g. 'present' | 'absent' | 'late' | 'leave'), consider defining a string-literal union type (e.g. `export type LessonAttendanceStatus = 'present' | 'absent' | 'late' | 'leave'`) and using it here and for `session` (e.g. 'morning' | 'afternoon'). This gives compile-time safety against typos at all usage sites. If the set is not fixed, an explicit comment explaining why plain `string` is used would help.","suggestion_code":null,"existing_code":" session: string;\n status: string;"}
{"path":"apps/admin/src/hooks/useApiMutation.ts","start_line":40,"end_line":40,"category":"bug","severity":"medium","content":"The error generic of useMutation is hard-coded to `Error`, but `mutationFn: (vars) => Promise<TData>` gives no guarantee that the rejection value is an `Error` (APIs commonly reject with strings or plain objects). As a result, `error` passed to `onError`/`onSettled` is typed `Error` even though the runtime value may not have a `.message`, and caller code such as `error.message` becomes type-unsound. Suggest exposing a `TError = Error` generic parameter (like `TContext`) and forwarding it to useMutation, or accepting `unknown` in the callback types.","suggestion_code":"export function useApiMutation<TData = unknown, TVars = void, TContext = unknown, TError = Error>(\n ...\n return useMutation<TData, TError, TVars, TContext>({","existing_code":"return useMutation<TData, Error, TVars, TContext>({"}
{"path":"apps/admin/src/hooks/useApiMutation.ts","start_line":49,"end_line":51,"category":"bug","severity":"medium","content":"`invalidateQueries` returns a promise that is discarded with `void` and not awaited before `options.onSuccess` runs. Two issues: (1) in the typical \"success → close modal\" flow, the refetch is started but the callback proceeds immediately, so the list can show stale data or the refetch can be cancelled on unmount, leaving the UI unrefreshed; (2) if a refetch rejects, the discarded promise produces an unhandled rejection. Consider making onSuccess async and awaiting all invalidations (React Query awaits the onSuccess promise before onSettled), or at minimum attach a `.catch`/`Promise.allSettled` to the invalidations.","suggestion_code":" onSuccess: async (data, vars, context) => {\n const keys = options.invalidate ?? [];\n await Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: key })));\n options.onSuccess?.(data, vars, context);\n },","existing_code":" for (const key of options.invalidate ?? []) {\n void queryClient.invalidateQueries({ queryKey: key });\n }"}
{"path":"apps/admin/src/components/RouteDock/dockTabs.ts","start_line":13,"end_line":13,"category":"bug","severity":"medium","content":"When the list overflows again after a previous clamp, the tab that was moved to the front by `[active, ...keptRest]` is treated as the \"oldest\" and evicted next, even though it may have been opened most recently. Example: at the 20-tab limit, opening X produces [X, B, C, ..., T]; opening Y next evicts X (kept at index 0) while keeping the much older B. This contradicts the stated \"淘汰最旧\" (evict oldest) intent, so eviction is not actually by recency once overflow occurs repeatedly. Consider evicting in place without reordering (e.g., drop the first non-active tab while preserving original order), or track recency separately.","suggestion_code":" // 保持原顺序,仅剔除最旧的非当前页签,避免 active-first 重排破坏后续淘汰次序\n const dropIndex = list.findIndex((tab) => tab.key !== activeKey);\n return dropIndex >= 0 ? list.filter((_, i) => i !== dropIndex) : list.slice(-MAX_DOCK_TABS);","existing_code":" return active ? [active, ...keptRest] : keptRest.slice(-MAX_DOCK_TABS);"}
{"path":"apps/admin/src/components/RouteDock/dockTabs.ts","start_line":12,"end_line":13,"category":"maintainability","severity":"low","content":"This defensive branch is effectively dead/unreachable from `upsertDockTab` (the active tab is always present in the list — either newly appended or already existing), and when it is reached, `keptRest` always has length `MAX_DOCK_TABS - 1`, so `slice(-MAX_DOCK_TABS)` is a no-op and the result is always one short of the limit. Simplify to avoid misleading readers.","suggestion_code":" return active ? [active, ...keptRest] : keptRest;","existing_code":" const keptRest = rest.slice(rest.length - (MAX_DOCK_TABS - 1));\n return active ? [active, ...keptRest] : keptRest.slice(-MAX_DOCK_TABS);"}
{"path":"apps/admin/src/components/RouteDock/dockTabs.ts","start_line":8,"end_line":8,"category":"maintainability","severity":"low","content":"`list as DockTab[]` discards the `readonly` modifier and returns the store's own array reference typed as mutable. The shared-reference behavior is deliberate (test asserts `toBe(tabs)`), but any caller that mutates the returned array would corrupt the persisted store state and any other component holding the same reference in place. Consider documenting the no-mutation contract, or returning a copy if mutation safety is preferred over the re-render optimization.","suggestion_code":null,"existing_code":" if (list.length <= MAX_DOCK_TABS) return list as DockTab[];"}
{"path":"apps/admin/src/hooks/useViewSensitive.ts","start_line":21,"end_line":30,"category":"bug","severity":"medium","content":"The destroy-on-revoked-permission behavior never actually runs: this effect has an empty dependency array, so it only executes once on mount (when `modalRef.current` is still null and the condition is a no-op) and once on unmount. Mutating `canLogRef.current` during render does not re-trigger the effect, so if `canLog` changes from true to false while a confirm dialog is open, the modal is never destroyed — contradicting the docstring (“when false any already-open confirm modal is destroyed”). Make the effect depend on `canLog` so the check re-runs when the permission changes.","suggestion_code":" useEffect(() => {\n if (!canLog && modalRef.current) {\n modalRef.current.destroy();\n modalRef.current = null;\n }\n return () => {\n modalRef.current?.destroy();\n modalRef.current = null;\n };\n }, [canLog]);","existing_code":" useEffect(() => {\n if (!canLogRef.current && modalRef.current) {\n modalRef.current.destroy();\n modalRef.current = null;\n }\n return () => {\n modalRef.current?.destroy();\n modalRef.current = null;\n };\n }, []);"}
{"path":"apps/admin/src/hooks/useViewSensitive.ts","start_line":35,"end_line":35,"category":"bug","severity":"low","content":"Opening a new confirm overwrites `modalRef.current` without destroying an already-open dialog. If the user triggers the action twice (e.g., for two students), two confirm modals stack on top of each other, and the earlier modal's `afterClose` clears the ref while the newer modal is still open, so a later revoke/unmount won't destroy it. Destroy the existing dialog (if any) before creating a new one.","suggestion_code":" modalRef.current?.destroy();\n modalRef.current = modal.confirm({","existing_code":" modalRef.current = modal.confirm({"}
{"path":"apps/admin/src/pages/Attendance/attendance-workspace.ts","start_line":130,"end_line":142,"category":"maintainability","severity":"medium","content":"The label assignment is a 5-level nested ternary chain, which violates the project rule that nested ternary expressions are not allowed. This is hard to read and maintain. Refactor into if/else branches or a lookup table.","suggestion_code":" let label: string;\n if (machine) {\n label = '考勤机打卡';\n } else if (source === 'USER') {\n label = '手机打卡';\n } else if (source.includes('BEACON') || source.includes('BLE')) {\n label = '蓝牙打卡';\n } else if (source.includes('WIFI')) {\n label = 'Wi-Fi 打卡';\n } else if (source.includes('APPROVE')) {\n label = '审批补卡';\n } else if (source) {\n label = `其他打卡(${record.punchSource}`;\n } else {\n label = '打卡来源未知';\n }","existing_code":" const label = machine\n ? '考勤机打卡'\n : source === 'USER'\n ? '手机打卡'\n : source.includes('BEACON') || source.includes('BLE')\n ? '蓝牙打卡'\n : source.includes('WIFI')\n ? 'Wi-Fi 打卡'\n : source.includes('APPROVE')\n ? '审批补卡'\n : source\n ? `其他打卡(${record.punchSource}`\n : '打卡来源未知';"}
{"path":"apps/admin/src/pages/Attendance/attendance-workspace.ts","start_line":22,"end_line":31,"category":"bug","severity":"medium","content":"Phase is computed purely from the time-of-day, so any schedule that crosses midnight (endTime < startTime, e.g. 22:0002:00) is misclassified: it will return 'ended' after startTime and 'upcoming' again before endTime. Additionally, seconds are ignored, so the phase stays 'ongoing' for up to one minute past the actual end time. Consider comparing against actual Date values (including the schedule date) or explicitly handling the endTime < startTime wrap-around.","suggestion_code":null,"existing_code":"export function getSchedulePhase(\n startTime: string,\n endTime: string,\n now = new Date(),\n): SchedulePhase {\n const current = now.getHours() * 60 + now.getMinutes();\n if (current < toMinuteOfDay(startTime)) return 'upcoming';\n if (current <= toMinuteOfDay(endTime)) return 'ongoing';\n return 'ended';\n}"}
{"path":"apps/admin/src/pages/Attendance/attendance-workspace.ts","start_line":56,"end_line":58,"category":"bug","severity":"low","content":"The `in` operator also matches inherited properties from Object.prototype (e.g. 'toString', 'constructor', 'hasOwnProperty'). If a record ever carries one of these status strings, the subsequent `summary[status] += 1` would read an inherited function/accessor and corrupt the summary (e.g. assigning a string/NaN to it), and `__proto__` could even mutate the object's prototype. Use `Object.hasOwn(summary, record.status)` or an explicit whitelist of allowed status keys instead.","suggestion_code":" if (Object.hasOwn(summary, record.status) && record.status !== 'total') {\n summary[record.status as Exclude<keyof AttendanceSummary, 'total'>] += 1;\n }","existing_code":" if (record.status in summary && record.status !== 'total') {\n summary[record.status as Exclude<keyof AttendanceSummary, 'total'>] += 1;\n }"}
{"path":"apps/admin/src/pages/Classes/teacher-candidate.ts","start_line":17,"end_line":17,"category":"maintainability","severity":"medium","content":"Role super-admin detection relies on Chinese display names (`'超级管理员'`, `'超管'`) in addition to the canonical code. Display names are not a stable identity — the codebase itself has a server test (`jwt.strategy.spec.ts`) asserting the role must be recognized via the canonical `code` even when the display name changes. If a super admin's display name is renamed, this predicate silently returns false and the user would be incorrectly included as a teacher candidate (a permission/authorization bypass). Prefer matching only the stable `role.code === 'super_admin'`, and extract the magic strings into shared role constants instead of hardcoding them here.","suggestion_code":" role.code === 'super_admin';","existing_code":" role.code === 'super_admin' || role.name === '超级管理员' || role.name === '超管';"}
{"path":"apps/admin/src/pages/Classes/teacher-candidate.ts","start_line":21,"end_line":21,"category":"maintainability","severity":"low","content":"The `'staff'` student-status value is a hardcoded business string. Per the review rules, business-related hardcoded strings should be extracted into shared constants/enums so the allowed status is defined in one place and stays consistent across the admin and server code that also reference these values.","suggestion_code":null,"existing_code":" if (user.studentStatus && user.studentStatus !== 'staff') return false;"}
{"path":"apps/admin/src/pages/Bills/bill-print.ts","start_line":46,"end_line":46,"category":"bug","severity":"medium","content":"`Number(value || 0)` returns `NaN` when the value is a non-numeric string (e.g. an unexpected API value like `\"abc\"`), and `NaN.toFixed(2)` renders `\"NaN\"`, so the printed bill would show `¥NaN`. Guard the conversion with `Number.isFinite` and fall back to `0.00`.","suggestion_code":"const money = (value: unknown) => {\n const n = Number(value ?? 0);\n return `¥${Number.isFinite(n) ? n.toFixed(2) : '0.00'}`;\n};","existing_code":"const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;"}
{"path":"apps/admin/src/pages/Bills/bill-print.ts","start_line":55,"end_line":57,"category":"bug","severity":"medium","content":"Same NaN risk as `money()`: if `days`/`totalRoomDays`/`studentAmount` come through as non-numeric strings, these cells will render literal `NaN` on the printed bill. Also, `Number(item.studentAmount || 0).toFixed(2)` duplicates the formatting logic in `money()` — reuse it for consistency.","suggestion_code":" <td>${Number.isFinite(Number(item.days)) ? Number(item.days) : 0}</td>\n <td>${Number.isFinite(Number(item.totalRoomDays)) ? Number(item.totalRoomDays) : 0}</td>\n <td>${money(item.studentAmount)}</td>","existing_code":" <td>${Number(item.days || 0)}</td>\n <td>${Number(item.totalRoomDays || 0)}</td>\n <td>${Number(item.studentAmount || 0).toFixed(2)}</td>"}
{"path":"apps/admin/src/pages/Bills/bill-print.ts","start_line":96,"end_line":96,"category":"maintainability","severity":"low","content":"Business-specific strings such as the institution name \"恭学教育基地\" and the footer \"学生管理系统\" are hardcoded directly in the template. Per the review rules, business-related hardcoded strings should be avoided — consider passing the institution/system name in via `BillPrintData` or a config so the template is reusable across tenants/systems.","suggestion_code":null,"existing_code":" <h1>恭学教育基地水电费账单</h1>"}
{"path":"apps/admin/src/pages/Occupancies/occupancy-form.ts","start_line":23,"end_line":23,"category":"maintainability","severity":"medium","content":"Hardcoded business value `'short'` as the default stayType. Per the review rules, business-related hardcoded strings should be avoided — extract this to a named constant (e.g., DEFAULT_STAY_TYPE) or let the form/backend decide. Silently defaulting every check-in to 'short' can also hide a missing selection in the UI.","suggestion_code":null,"existing_code":"stayType: values.stayType || 'short',"}
{"path":"apps/admin/src/pages/Occupancies/occupancy-form.ts","start_line":25,"end_line":25,"category":"maintainability","severity":"medium","content":"Hardcoded deposit default of `500` is a business number that should be a named constant (e.g., DEFAULT_DEPOSIT_AMOUNT) or configuration, so it isn't duplicated/silently drifted across the codebase.","suggestion_code":null,"existing_code":"depositAmount: collectDeposit ? values.depositAmount ?? 500 : undefined,"}
{"path":"apps/admin/src/pages/Occupancies/occupancy-form.ts","start_line":28,"end_line":28,"category":"bug","severity":"low","content":"`values.lockerId || undefined` converts a legitimate `0` to `undefined` because `||` treats falsy values as missing. Since lockerId is a number, use nullish coalescing or pass the value through directly.","suggestion_code":"lockerId: values.lockerId,","existing_code":"lockerId: values.lockerId || undefined,"}
{"path":"apps/admin/src/pages/Occupancies/occupancy-form.ts","start_line":45,"end_line":45,"category":"bug","severity":"low","content":"Same issue as the check-in payload: `values.newLockerId || undefined` drops a valid `0`. Use nullish coalescing or pass the value directly.","suggestion_code":"newLockerId: values.newLockerId,","existing_code":"newLockerId: values.newLockerId || undefined,"}
{"path":"apps/admin/src/pages/Exams/types.ts","start_line":1,"end_line":1,"category":"maintainability","severity":"low","content":"dayjs is a value (a function) with a merged namespace, so default-importing it via `import type` just to reach the `Dayjs` type is fragile and non-idiomatic (it also fails under strict TS configs such as `verbatimModuleSyntax` in some versions). Prefer importing the named type directly: `import type { Dayjs } from 'dayjs';` and then declare `examDate: Dayjs`.","suggestion_code":"import type { Dayjs } from 'dayjs';","existing_code":"import type dayjs from 'dayjs';"}
{"path":"apps/admin/src/pages/Exams/types.ts","start_line":30,"end_line":31,"category":"maintainability","severity":"low","content":"The exported array is inferred as `{ value: string; label: string }[]` and is mutable, so consumers could push/overwrite entries and the literal values get widened to `string`, losing any compile-time link to `ExamItem['examType']` or `ExamFormValues['examType']`. Add `as const` (and optionally make it `readonly`) so the option values stay as literal types and the array can't be mutated globally.","suggestion_code":"export const EXAM_TYPE_OPTIONS = [\n { value: '月考', label: '月考' },\n { value: '周测', label: '周测' },\n { value: '期中考试', label: '期中考试' },\n { value: '期末考试', label: '期末考试' },\n { value: '模拟考试', label: '模拟考试' },\n { value: '入学测试', label: '入学测试' },\n] as const;","existing_code":"export const EXAM_TYPE_OPTIONS = [\n { value: '月考', label: '月考' },"}
{"path":"apps/admin/src/pages/Dashboard/DashboardCharts.ts","start_line":203,"end_line":206,"category":"security","severity":"medium","content":"The tooltip formatter interpolates the student name (`p.data.name`) directly into an HTML string. ECharts injects custom tooltip formatter output into the tooltip DOM via innerHTML, so a user-controlled student name containing HTML (e.g. `<img onerror=...>`) can execute — an XSS risk. Escape the name before embedding (e.g. use ECharts' `encodeHTML` helper), or otherwise sanitize it.","suggestion_code":null,"existing_code":" tooltip: {\n formatter: (p: { data: { name: string; value: [string, string, string, boolean] } }) =>\n `${p.data.name}<br/>入住: ${p.data.value[1]}<br/>退宿: ${p.data.value[2]}`,\n },"}
{"path":"apps/admin/src/pages/Dashboard/DashboardCharts.ts","start_line":160,"end_line":164,"category":"security","severity":"low","content":"Same XSS concern: the classroom name (`p.name`) is interpolated into the tooltip HTML string without escaping. If classroom names are user-editable, an HTML/script payload could be executed in the tooltip. Escape the name before concatenating.","suggestion_code":null,"existing_code":" formatter: (p: {\n name: string;\n data: { scheduleDays: number; rentalCount: number; occupancy: number };\n }) =>\n `${p.name}<br/>排课: ${p.data.scheduleDays}天 租赁: ${p.data.rentalCount}个 占用率: ${(p.data.occupancy * 100).toFixed(0)}%`,"}
{"path":"apps/admin/src/pages/Dashboard/DashboardCharts.ts","start_line":232,"end_line":237,"category":"bug","severity":"low","content":"When a check-in date is later than the computed end (e.g. a future booking beyond `periodEnd`/`today`, or malformed dates), `end[0] - start[0]` is negative and `Math.max(..., 2)` draws a misleading 2px bar anchored at the start coordinate; if `api.coord` cannot resolve a date it can even return NaN. Validate the dates/coords (e.g. `isNaN` checks) and skip or clamp the bar for invalid ranges instead of rendering a fake 2px bar.","suggestion_code":null,"existing_code":" const rectShape = {\n x: start[0],\n y: start[1] - height / 2,\n width: Math.max(end[0] - start[0], 2),\n height,\n };"}
{"path":"apps/admin/src/pages/Dashboard/DashboardCharts.ts","start_line":133,"end_line":136,"category":"maintainability","severity":"low","content":"`buildExpensePieOption` duplicates nearly the entire structure of `buildAttendanceRingOption` (same pie type, radius, center, legend, COLORS). Consider extracting a shared `buildRingPieOption(data, colors)` helper and have both functions delegate to it to avoid drift between the two charts.","suggestion_code":null,"existing_code":"export function buildExpensePieOption(\n rows: ExpenseByTypeRow[],\n expenseTypeMap: Record<string, string>,\n): EChartsOption {"}
{"path":"apps/admin/src/pages/Dashboard/DashboardCharts.ts","start_line":51,"end_line":51,"category":"maintainability","severity":"low","content":"Chart colors are hardcoded literals (`#007AFF`, `#34C759`, `#FF9500`, `#1677ff`, rgba overlays) even though a shared `COLORS` palette is already imported in this module. Using the theme constant keeps chart colors consistent and makes future rebranding a one-line change; prefer `COLORS`/theme tokens over ad-hoc hex values.","suggestion_code":null,"existing_code":" itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },"}
{"path":"apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts","start_line":64,"end_line":64,"category":"maintainability","severity":"medium","content":"Nested ternary expression (dirty ? formValues : config ? ... : {}) violates the project rule that prohibits nested ternaries and hurts readability. Extract the branch into a local variable using if/else, e.g.:\n\n```ts\nconst { dirty, formValues } = get();\nlet nextFormValues: Partial<DingTalkConfigFormValues> = {};\nif (!dirty) {\n nextFormValues = config ? { ...config, appSecret: undefined } : {};\n} else {\n nextFormValues = formValues;\n}\n```","suggestion_code":null,"existing_code":" formValues: dirty ? formValues : config ? { ...config, appSecret: undefined } : {},"}
{"path":"apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts","start_line":75,"end_line":75,"category":"maintainability","severity":"low","content":"The `{ ...config, appSecret: undefined }` snapshot-building logic is duplicated in `cacheServerSnapshot` and `commitConfig`. Extract it into a small helper (e.g. `configToFormValues = (config: DingTalkSavedConfig) => ({ ...config, appSecret: undefined })`) to keep the two actions in sync and avoid future divergence.","suggestion_code":null,"existing_code":" formValues: { ...config, appSecret: undefined },"}
{"path":"apps/admin/src/pages/IntegrationConfig/integrationConfigStore.ts","start_line":84,"end_line":84,"category":"bug","severity":"low","content":"`import.meta.env.DEV` is evaluated at module load time when the store is created. If this module is ever imported in a non-Vite environment (e.g., a Node/Jest test runner, SSR, or build tooling where `import.meta`/`import.meta.env` is undefined), this line throws a TypeError and breaks the entire store before any state logic can run. Guard the access, e.g. `enabled: typeof import.meta !== 'undefined' && import.meta.env?.DEV === true` or use a try/catch wrapper, to make the store safely importable in any environment.","suggestion_code":null,"existing_code":" { name: 'integration-config-store', enabled: import.meta.env.DEV },"}
{"path":"apps/admin/src/pages/Schedules/sync-result.ts","start_line":28,"end_line":28,"category":"bug","severity":"medium","content":"`hasFailure` does not consider `failedItems > 0`, but the warning/error messages below count on `failedItems`. If a caller reports `failedItems > 0` without setting `failedBatchCount` or `errors` (e.g., item-level failures not attached to a failed batch), the result is misclassified as a full `success` even though items failed. Also, when `errors` is non-empty but `failedItems === 0`, the error message would read \"0 条全部写入失败\". Make the failure detection consistent with the message fields, e.g. include `failedItems > 0` in `hasFailure` and guard the message when `failedItems === 0`.","suggestion_code":"const hasFailure = result.failedBatchCount > 0 || result.failedItems > 0 || result.errors.length > 0;","existing_code":"const hasFailure = result.failedBatchCount > 0 || result.errors.length > 0;"}
{"path":"apps/admin/src/pages/Schedules/sync-result.ts","start_line":3,"end_line":6,"category":"maintainability","severity":"low","content":"`scheduleCount` and `skippedNoMapping` are declared in `SyncResultInput` but never read by `classifySyncResult` (or any other code in this module). If they are not needed by callers either, they are dead fields and should be removed; if they are part of the shared input contract, consider documenting that they are unused by the classifier.","suggestion_code":null,"existing_code":"export interface SyncResultInput {\n scheduleCount: number;\n syncedItems: number;\n skippedNoMapping: number;"}
{"path":"apps/admin/src/pages/archive-view.ts","start_line":29,"end_line":29,"category":"maintainability","severity":"low","content":"This is a nested ternary expression, which violates the project rule that nested ternaries are not allowed (it significantly hurts readability). Prefer extracting the logic into a lookup map or a small if/else helper, e.g. `batchAction: ({ active: 'checkout', all: 'archive', archived: 'restore' } as const)[view]`.","suggestion_code":"batchAction: ({ active: 'checkout', all: 'archive', archived: 'restore' } as const)[view],","existing_code":"batchAction: view === 'active' ? 'checkout' : view === 'all' ? 'archive' : 'restore',"}
{"path":"apps/admin/src/pages/archive-view.ts","start_line":22,"end_line":26,"category":"bug","severity":"medium","content":"The `ViewPolicy` interface documents that `purgeBatch` (batch permanent delete) is mutually exclusive with batch restore (\"与批量恢复互斥\"), but `archiveViewPolicy` for the `archived` view sets both `batchAction: 'restore'` and `purgeBatch: true`. This means both actions would be active at the same time, contradicting the documented design. Fix the policy so the two are mutually exclusive (e.g. only expose purge when batchAction is not 'restore'), or correct the documentation.","suggestion_code":null,"existing_code":"export const archiveViewPolicy = (view: ArchiveView): ViewPolicy => ({\n batchAction: view === 'archived' ? 'restore' : 'archive',\n readonly: view === 'archived',\n purgeBatch: view === 'archived',\n});"}
{"path":"apps/admin/src/pages/archive-view.ts","start_line":20,"end_line":20,"category":"maintainability","severity":"low","content":"`expenseStatusForView` is an identity function that returns its argument unchanged — it performs no transformation. Either the mapping to expense status was never implemented (dead/incomplete code) or this helper serves no purpose. If it is meant to map a view to an expense status, implement the actual mapping; otherwise remove it to avoid misleading dead code.","suggestion_code":null,"existing_code":"export const expenseStatusForView = (view: ArchiveView) => view;"}
{"path":"apps/admin/src/pages/archive-view.ts","start_line":37,"end_line":40,"category":"bug","severity":"medium","content":"For the `all` view this function returns `status: 'active'`, which would filter out archived records — contradicting the semantics of an 'all' view (should include both active and archived records). Verify the API semantics; the 'all' view most likely should not pass a restrictive status filter, e.g. `status: view === 'archived' ? 'archived' : undefined` with `active` handled separately.","suggestion_code":null,"existing_code":"export const occupancyParamsForView = (view: OccupancyView) => ({\n active: view === 'active' ? 'true' : undefined,\n status: view === 'archived' ? 'archived' : 'active',\n});"}
{"path":"apps/admin/src/pages/Schedules/schedule-visibility.ts","start_line":17,"end_line":17,"category":"style","severity":"low","content":"`==`/`!=` are prohibited by the project rules; strict equality must be used. Since `classId` is an optional parameter that can be `undefined` or `null`, replace the loose nullish check with an explicit strict check: `if (classId === undefined || classId === null) return schedules;`","suggestion_code":" if (classId === undefined || classId === null) return schedules;","existing_code":" if (classId == null) return schedules;"}
{"path":"apps/admin/src/store/app/appTypes.ts","start_line":36,"end_line":39,"category":"maintainability","severity":"low","content":"The persisted subset duplicates field declarations from AppState (`sidebarCollapsed`, `routeDockTabs`). If AppState is changed, this subset can silently drift out of sync, and the redundant boolean/DockTab types are repeated. Consider deriving it with `Pick<AppState, 'sidebarCollapsed' | 'routeDockTabs'>` to keep a single source of truth.","suggestion_code":"export type AppPersistedState = Pick<AppState, 'sidebarCollapsed' | 'routeDockTabs'>;","existing_code":"export interface AppPersistedState {\n sidebarCollapsed: boolean;\n routeDockTabs: DockTab[];\n}"}
{"path":"apps/admin/src/pages/Rooms/useRoomMutations.ts","start_line":106,"end_line":106,"category":"maintainability","severity":"medium","content":"`editing` is typed as `any`, which hides a real hazard: the truthiness check `editing ? api.put(...) : api.post(...)` means an empty object `{}` (or a stale object without `id`) would still be truthy and produce a PUT to `/rooms/undefined`. Type it explicitly, e.g. `editing: { id: number } | null | undefined`, so the create/update branch is guarded by the type system and callers can't pass malformed objects.","suggestion_code":"export function useRoomMutations(editing: { id: number } | null | undefined) {","existing_code":"export function useRoomMutations(editing: any) {"}
{"path":"apps/admin/src/pages/Rooms/useRoomMutations.ts","start_line":113,"end_line":113,"category":"maintainability","severity":"medium","content":"`record` is typed as `any`, and `record.id` is dereferenced without any null/type check, so a malformed record silently yields `/rooms/undefined`. Type it as `{ id: number }` (plus any fields used by the caller) to make the cell-edit contract explicit.","suggestion_code":"async ({ record, field, value }: { record: { id: number }; field: string; value: unknown }) =>","existing_code":"async ({ record, field, value }: { record: any; field: string; value: unknown }) =>"}
{"path":"apps/admin/src/pages/Rooms/useRoomMutations.ts","start_line":53,"end_line":53,"category":"maintainability","severity":"medium","content":"`useLockerMutations` is a near-verbatim copy of `useBedMutations` (cell save, save/create, delete, batch — identical shapes except for the URL segment `beds`/`lockers`). This duplication is a maintenance hazard: any change to one (e.g., new endpoint, different invalidate keys) is easy to miss in the other. Consider extracting a shared factory, e.g. `useSubItemMutations(resource: 'beds' | 'lockers')` returning `saveCellMutation/saveMutation/deleteMutation/batchMutation`, then having both hooks delegate to it.","suggestion_code":null,"existing_code":"export function useLockerMutations() {"}
{"path":"apps/admin/src/pages/Schedules/schedule-form.ts","start_line":38,"end_line":38,"category":"bug","severity":"medium","content":"Both start/end times are anchored to the same fixed date `2000-01-01`. For schedules that span midnight (e.g. 23:0000:30), `timeRange[1]` will be earlier than `timeRange[0]`, so any end-after-start comparison in the form fails and `buildSchedulePayload` sends a bare `00:30` endTime with no next-day semantics. Consider anchoring the end time to the following day when `endTime < startTime` (or storing times as time-of-day strings and normalizing in the payload builder).","suggestion_code":null,"existing_code":"timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],"}
{"path":"apps/admin/src/pages/Schedules/schedule-form.ts","start_line":37,"end_line":37,"category":"maintainability","severity":"low","content":"Business magic values are hardcoded: the anchor date `2000-01-01` used when reconstructing times and the default `30` for `attendanceAdvanceMinutes`. These should be extracted into named constants so the default advance-minutes policy and the time anchor are defined once and documented (also making the timeRange rebuild logic easier to fix/reuse).","suggestion_code":null,"existing_code":"attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes ?? 30,"}
{"path":"apps/admin/src/pages/Occupancies/useOccupancyMutations.ts","start_line":4,"end_line":5,"category":"maintainability","severity":"low","content":"`invalidateOccupancies` is recreated on every render with a loose type `Array<readonly unknown[]>` that doesn't match the `QueryKey[]` contract used by `useApiMutation` (type safety is only rescued by structural compatibility). Since the value is static, hoist it to module scope as a typed constant (e.g. `const INVALIDATE_OCCUPANCIES: QueryKey[] = [['occupancies']];`) and reference it from all mutations. This avoids per-render array allocation (the same object is passed into 10 `useApiMutation` calls each render) and provides a single source of truth for the invalidation key.","suggestion_code":"const INVALIDATE_OCCUPANCIES: QueryKey[] = [['occupancies']];\n\nexport function useOccupancyMutations() {","existing_code":"export function useOccupancyMutations() {\n const invalidateOccupancies: Array<readonly unknown[]> = [['occupancies']];"}
{"path":"apps/admin/src/pages/Occupancies/useOccupancyMutations.ts","start_line":46,"end_line":47,"category":"bug","severity":"medium","content":"`importMutation` appends a raw `params` string to the URL. If `params` is empty the request goes to `/occupancies/import?` (stray `?`), and if it contains special characters (spaces, `#`, `&`, non-ASCII) they are not URL-encoded, which can silently break the import request or send wrong values. Prefer accepting a `Record<string, unknown>` object and serializing it with `URLSearchParams` (skipping the empty case) so encoding is handled consistently.","suggestion_code":" async ({ formData, params }: { formData: FormData; params: Record<string, unknown> }) => {\n const query = new URLSearchParams(params as Record<string, string>).toString();\n return api.post(`/occupancies/import${query ? `?${query}` : ''}`, formData);\n },","existing_code":" async ({ formData, params }: { formData: FormData; params: string }) =>\n api.post(`/occupancies/import?${params}`, formData),"}
{"path":"apps/admin/src/store/permission/permissionTypes.ts","start_line":6,"end_line":7,"category":"maintainability","severity":"low","content":"The store state's permissions array is treated as immutable (the implementation in permissionStore.ts always replaces it via `[...new Set(permissions)]` on write), but the type declares a mutable `string[]`. Declaring `readonly string[]` would make the immutability contract explicit and prevent consumers from accidentally mutating shared state, which can lead to missed re-renders in Zustand. The same applies to `PermissionPersistedState.permissions`.","suggestion_code":" /** 已校验通过的权限码 */\n permissions: readonly string[];","existing_code":" /** 已校验通过的权限码 */\n permissions: string[];"}
{"path":"apps/admin/src/store/app/appStore.ts","start_line":62,"end_line":63,"category":"maintainability","severity":"low","content":"Rehydration of persisted state is only validated when the stored version differs from the current version (via migrateAppUiState). In the common case (version matches), zustand's default merge shallow-merges the raw persisted `routeDockTabs`/`sidebarCollapsed` directly into the store without any structural validation (no isDockTab-style filtering). Since RouteDock consumes `tab.key`/`tab.label` from `routeDockTabs`, a stale or hand-edited `gongxue-app-ui` value (e.g., non-object entries, missing keys) would flow into the store and break tab rendering, while the legacy path would have been sanitized. Consider adding a `merge` option that re-runs the same normalization as `migrateAppUiState` (filtering invalid tabs and coercing `sidebarCollapsed` to a boolean) so the same-version path is as defensive as the migration path.","suggestion_code":null,"existing_code":" version: 2,\n migrate: migrateAppUiState,"}
{"path":"apps/admin/src/store/user/userTypes.ts","start_line":32,"end_line":35,"category":"maintainability","severity":"low","content":"`UserPersistedState` is structurally identical to `UserState` (`{ token; user }`). Keeping two separate declarations that must be manually kept in sync invites drift whenever `UserState` changes. Consider deriving the persisted type instead, e.g. `export type UserPersistedState = Pick<UserState, 'token' | 'user'>;` (or `UserState` itself if it stays the persisted subset).","suggestion_code":"export type UserPersistedState = Pick<UserState, 'token' | 'user'>;","existing_code":"export interface UserPersistedState {\n token: string | null;\n user: UserInfo | null;\n}"}
{"path":"apps/admin/src/store/user/userTypes.ts","start_line":37,"end_line":38,"category":"maintainability","severity":"low","content":"`StoreStatus` is imported and re-exported here, but nothing in the codebase consumes `StoreStatus` from this module (it is only used via `../types` and `store/index.ts`), so this re-export is dead code. Also, the attached comment talks about permissions being managed by the permission store, which is unrelated to `StoreStatus` — either remove the import/re-export or move the comment next to the `permissions` field in `UserInfo` to avoid confusion.","suggestion_code":null,"existing_code":"/** 兼容:用户状态中的权限由 permission Store 统一管理 */\nexport type { StoreStatus };"}
{"path":"apps/admin/src/store/user/userActions.ts","start_line":23,"end_line":29,"category":"maintainability","severity":"low","content":"When `state.user` is null this returns `{}`, which silently discards the patch and still triggers a full `set` — notifying listeners and causing the `persist` middleware to run even though nothing changed. Callers who call `updateUser` before a session exists will have their updates silently lost, and the redundant state update is wasted work. Consider guarding outside the set (e.g., using `get()` and bailing out) or logging a warning when user is null, so the no-op is explicit.","suggestion_code":" updateUser: (patch) => {\n set(\n (state) => {\n if (!state.user) return {};\n return { user: { ...state.user, ...patch } };\n },\n false,\n 'user/updateUser',\n );\n },","existing_code":" updateUser: (patch) => {\n set(\n (state) => (state.user ? { user: { ...state.user, ...patch } } : {}),\n false,\n 'user/updateUser',\n );\n },"}
{"path":"apps/admin/src/store/editableCell/editableCellStore.ts","start_line":17,"end_line":18,"category":"maintainability","severity":"low","content":"Non-serializable data in store state: `activeCell` holds a `save` closure (a function). While zustand itself tolerates this, the value is dropped/unreadable in Redux DevTools state snapshots, and any future use of the `persist` middleware or serialization-based testing would silently lose it. Consider storing the session id + serializable metadata in the store and keeping the live `save` closure in a module-level map/ref keyed by id (retrievable via a selector), which also keeps the command-based design.","suggestion_code":null,"existing_code":" /** 当前处于编辑态的单元格(全局唯一) */\n activeCell: EditableCellSession | null;"}
{"path":"apps/admin/src/store/editableCell/editableCellStore.ts","start_line":35,"end_line":35,"category":"maintainability","severity":"low","content":"`setActiveCell` unconditionally replaces the current session without invoking the previous session's `save()`. The “save the previously active cell before switching” invariant is therefore not enforced by this coordination hub — it relies on every caller doing it manually (today only `EditableCell/beginEdit` does, and it aborts on `saved === false`). Any future caller that sets `setActiveCell` directly will silently lose the previous cell's pending edits. Consider centralizing this: e.g., an async action that saves the old active cell and only replaces it on success, so the invariant cannot be bypassed.","suggestion_code":null,"existing_code":" setActiveCell: (session) => set({ activeCell: session }, false, 'editableCell/setActiveCell'),"}
{"path":"apps/admin/src/utils/download.ts","start_line":15,"end_line":18,"category":"bug","severity":"medium","content":"When the store has no token (e.g., not logged in, or a public endpoint like '/rooms/template'), this sends `Authorization: Bearer undefined`, which can cause the server to reject requests that would otherwise succeed. Build the header conditionally so it's only sent when a token exists.","suggestion_code":" const token = useUserStore.getState().token;\n const headers = token ? { Authorization: `Bearer ${token}` } : {};\n const res = await fetch(`${baseURL}${endpoint}`, { headers });","existing_code":" const token = useUserStore.getState().token;\n const res = await fetch(`${baseURL}${endpoint}`, {\n headers: { Authorization: `Bearer ${token}` },\n });"}
{"path":"apps/admin/src/utils/download.ts","start_line":16,"end_line":20,"category":"bug","severity":"medium","content":"`fetch` rejects with a network-level error (DNS failure, connection refused, timeout, etc.) rather than returning a non-ok response. That error is not caught here, so callers get a raw TypeError instead of a user-friendly message, and any `catch` in the caller may not distinguish network failures from HTTP failures. Wrap the request in try/catch and throw a friendly message.","suggestion_code":" let res: Response;\n try {\n res = await fetch(`${baseURL}${endpoint}`, {\n headers: { Authorization: `Bearer ${token}` },\n });\n } catch {\n throw new Error('网络错误,下载失败,请稍后重试');\n }\n\n if (!res.ok) {","existing_code":" const res = await fetch(`${baseURL}${endpoint}`, {\n headers: { Authorization: `Bearer ${token}` },\n });\n\n if (!res.ok) {"}
{"path":"apps/admin/src/utils/download.ts","start_line":11,"end_line":13,"category":"maintainability","severity":"low","content":"The dev API base URL is hardcoded to `http://localhost:...`. This makes it impossible to point the admin app at a non-local API without code changes, and breaks if the app is served from a different origin. Prefer a configurable env var (e.g., `VITE_API_BASE_URL`) with the localhost value as a fallback, or reuse the same API base used elsewhere in the app.","suggestion_code":" const baseURL =\n import.meta.env.VITE_API_BASE_URL ||\n (import.meta.env.PROD\n ? '/api'\n : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`);","existing_code":" const baseURL = import.meta.env.PROD\n ? '/api'\n : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;"}
{"path":"apps/admin/src/store/settings/settingsStore.ts","start_line":38,"end_line":39,"category":"bug","severity":"low","content":"Persisted settings are rehydrated with zustand's default shallow merge and no `migrate`, so structurally valid but malformed stored data is merged as-is. If localStorage contains e.g. `{\"state\":{\"aiChat\":null},\"version\":1}` (from a manual edit or a future bug), `state.aiChat` becomes `null` and `toggleAiChatDeepThinking` will throw `Cannot read properties of null (reading 'deepThinking')`. All other persisted stores in this project (auth/permission/app-ui, see store/middleware/persist.ts) validate/migrate their persisted state; this store should follow the same pattern. Add a defensive `merge` (or a `migrate` for future schema changes) that validates and defaults `aiChat`.","suggestion_code":" partialize: (state): SettingsState => ({ aiChat: state.aiChat }),\n version: 1,\n merge: (persisted, current) => {\n const p = persisted as Partial<SettingsState> | undefined;\n return {\n ...current,\n aiChat: {\n deepThinking: p?.aiChat?.deepThinking ?? current.aiChat.deepThinking,\n },\n };\n },","existing_code":" partialize: (state): SettingsState => ({ aiChat: state.aiChat }),\n version: 1,"}
{"path":"apps/admin/src/ui/app-message.ts","start_line":10,"end_line":12,"category":"bug","severity":"medium","content":"`requireMessageApi()` throws when `bindMessageApi` hasn't run yet (e.g., AppMessageBridge not yet mounted, or unmounted on a route that doesn't render it). Since this `message` facade is called from error handlers too (e.g., `useApiMutation`'s default `onError` calls `message.error`), the throw propagates into those paths and masks the original error (or breaks unrelated logic). Consider falling back to antd's static `message` API or degrading to a no-op with a dev warning instead of throwing.","suggestion_code":null,"existing_code":"if (!messageApi) {\n throw new Error('Ant Design message API has not been initialized');\n }"}
{"path":"apps/admin/src/ui/app-message.ts","start_line":5,"end_line":7,"category":"maintainability","severity":"medium","content":"The module-level `messageApi` singleton is only ever set, never reset. If the `AppMessageBridge` that bound it unmounts (route change, conditional render, tests), all subsequent `message.*` calls still target the detached instance and messages will silently fail to display. Add a corresponding `unbindMessageApi()`/cleanup (called from the bridge's effect cleanup) or track the binding lifecycle to avoid stale references.","suggestion_code":null,"existing_code":"export function bindMessageApi(api: MessageInstance): void {\n messageApi = api;\n}"}
{"path":"apps/admin/src/utils/operation-id.ts","start_line":1,"end_line":1,"category":"bug","severity":"low","content":"`crypto.randomUUID` is only available in secure contexts (HTTPS/localhost) and in relatively modern browsers (Chrome 92+, Firefox 95+, Safari 15.4+). In a non-secure context it is `undefined`, so this utility would throw at runtime and break every API call that relies on an operationId. Consider adding a fallback using `crypto.getRandomValues` (or a small UUID v4 implementation) to make the utility robust.","suggestion_code":"export const newOperationId = () => {\n if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();\n const bytes = crypto.getRandomValues(new Uint8Array(16));\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n return [...bytes].map((b, i) =>\n [4, 6, 8, 10].includes(i) ? `-${b.toString(16).padStart(2, '0')}` : b.toString(16).padStart(2, '0')\n ).join('');\n};","existing_code":"export const newOperationId = () => crypto.randomUUID();"}
{"path":"apps/admin/src/test/setup.ts","start_line":22,"end_line":24,"category":"bug","severity":"medium","content":"Test isolation gap: the stores persist under `gongxue-auth`, `gongxue-app-ui`, `gongxue-settings` (and `permissions`) via zustand persist (see store/middleware/persist.ts). Here only the legacy `token`/`user` keys and `permissions` are removed — the actual persist keys are never cleared. If any test fails/crashes before the store-reset lines run, stale persisted state (e.g. auth session, AI settings, UI state) leaks into subsequent tests in the same browser session. Prefer removing the real storage keys (e.g. import and use `AUTH_STORAGE_NAME`, `APP_UI_STORAGE_NAME`, `SETTINGS_STORAGE_NAME`, `PERMISSION_STORAGE_NAME`) so cleanup does not depend on store actions executing successfully.","suggestion_code":null,"existing_code":" localStorage.removeItem('token');\n localStorage.removeItem('user');\n localStorage.removeItem('permissions');"}
{"path":"apps/admin/src/test/setup.ts","start_line":35,"end_line":35,"category":"bug","severity":"low","content":"Shallow setState replaces the entire nested `aiChat` object rather than merging it, so any future fields added to `AiChatSettings` (e.g. model, temperature) would be silently dropped and persisted to `gongxue-settings`. Use the store's existing action `useSettingsStore.getState().setAiChatDeepThinking(false)`, which already merges with a spread, to keep the reset robust.","suggestion_code":null,"existing_code":" useSettingsStore.setState({ aiChat: { deepThinking: false } });"}
{"path":"apps/admin/src/test/setup.ts","start_line":12,"end_line":12,"category":"maintainability","severity":"low","content":"Hardcoded URL and inconsistent comment: `BASE` is hardcoded to `localhost:3002` while the comment above claims the dev server proxies `/api → localhost:3003`. Use an environment variable (e.g. `import.meta.env.VITE_API_BASE_URL`) and fix the comment so the setup matches the actual dev server, otherwise integration tests silently hit the wrong target when the port changes.","suggestion_code":null,"existing_code":"const BASE = 'http://localhost:3002';"}
{"path":"apps/admin/src/store/user/userStore.ts","start_line":22,"end_line":22,"category":"security","severity":"low","content":"The auth token is persisted to localStorage via the persist middleware. localStorage is readable by any script executed on the page, so a single XSS can exfiltrate the credential (unlike an httpOnly cookie, which is not accessible to JS). If the backend supports it, prefer an httpOnly secure cookie for the token, or at minimum document this as an accepted risk and ensure all rendered user input is escaped. Note the migrate/fallback logic also relies on reading/writing raw localStorage.","suggestion_code":null,"existing_code":"storage: authPersistStorage,"}
{"path":"apps/admin/src/store/user/userStore.ts","start_line":15,"end_line":15,"category":"maintainability","severity":"low","content":"`get` and `api` are destructured and forwarded to `createUserActions(set, get, api)`, but `createUserActions` only consumes `set` (its StateCreator signature declares only `set`). These parameters are effectively dead here; simplify to `(set) => ({ ...createUserActions(set) })` to avoid implying the actions depend on `get`/`api`.","suggestion_code":"(set) => ({","existing_code":"(set, get, api) => ({"}
{"path":"apps/admin/src/utils/error.ts","start_line":11,"end_line":13,"category":"bug","severity":"medium","content":"Backends commonly return validation errors as an array (e.g. `{ message: ['field is required'] }`), use a different key (`error`/`msg`), or return the response `data` as a plain string. In all these cases this branch discards the server-provided detail and falls back to the generic axios `error.message` (e.g. 'Request failed with status code 400'), so the user sees no actionable reason for the failure. Consider narrowing `data`'s runtime type: use `data` directly when it's a string, and join/stringify array or object `message` values before falling back to `error.message`.","suggestion_code":" const data = error.response?.data;\n if (typeof data === 'string' && data) message = data;\n else if (data && typeof data === 'object') {\n const msg = (data as { message?: unknown }).message;\n if (typeof msg === 'string' && msg) message = msg;\n else if (Array.isArray(msg) && msg.length) message = msg.join('; ');\n else if (msg && typeof msg === 'object') message = JSON.stringify(msg);\n }\n if (!message && error.message) message = error.message;","existing_code":" const data = error.response?.data as { message?: unknown } | undefined;\n if (typeof data?.message === 'string' && data.message) message = data.message;\n else if (error.message) message = error.message;"}
{"path":"apps/admin/src/utils/error.ts","start_line":22,"end_line":22,"category":"other","severity":"low","content":"The `.replace(/ {2,}/g, ' ')` collapses every run of multiple spaces, which can corrupt error text that intentionally contains indentation or aligned formatting (e.g. SQL statements, stack traces, key-value output). If preserving such formatting matters, consider replacing only newline runs (and normalizing tabs) and leaving repeated spaces intact.","suggestion_code":null,"existing_code":" const singleLine = trimmed.replace(/[\\n\\r]+/g, ' ').replace(/ {2,}/g, ' ');"}
{"path":"apps/admin/src/utils/notification-display.ts","start_line":21,"end_line":24,"category":"bug","severity":"medium","content":"`replaceAll(roleType, label)` performs a raw substring replacement with no token boundary, so any snake_case key embedded inside a larger token (e.g. `subject_teacher_id`, `head_teacher_notes`, or a URL path containing `/head_teacher/`) will be partially rewritten and corrupt the text. Since `_` is a word character, `\\b` word-boundary regexes won't help either. Prefer a single-pass alternation replacement so only exact key tokens are substituted:","suggestion_code":" const rolePattern = new RegExp(\n Object.keys(teacherRoleLabels)\n .sort((a, b) => b.length - a.length)\n .join('|'),\n 'g',\n );\n return (text ?? '').replace(rolePattern, (match) => teacherRoleLabels[match]);","existing_code":" return Object.entries(teacherRoleLabels).reduce(\n (result, [roleType, label]) => result.replaceAll(roleType, label),\n text,\n );"}
{"path":"apps/admin/src/utils/notification-display.ts","start_line":20,"end_line":20,"category":"bug","severity":"medium","content":"`formatNotificationText` dereferences `text` with no null guard. Callers pass `item.title` / `item.content` directly (e.g. `formatNotificationText(item.title)`), so if a notification record is missing one of these fields this throws a TypeError and breaks the whole notification list render. Add a defensive default value.","suggestion_code":"export function formatNotificationText(text = ''): string {","existing_code":"export function formatNotificationText(text: string): string {"}
{"path":"apps/admin/src/store/permission/permissionStore.ts","start_line":24,"end_line":26,"category":"security","severity":"low","content":"Fail-open window during background refresh: when `status === 'ready'`, `beginPermissionVerification` early-returns, leaving `status` at 'ready' and the old permissions active with no dedicated 'refreshing/verifying' sub-state. If the background verification fails (e.g., 401/network) and the caller does not invoke `clearPermissions` (or the failure path is missed), previously revoked permissions remain marked 'ready' and continue to take effect — which contradicts the fail-closed intent stated in the header comment. Consider introducing a distinct 'refreshing' status (or an in-flight flag) so the store itself can distinguish verified-from stale, and/or documenting a hard requirement that every refresh failure must call `clearPermissions`.","suggestion_code":null,"existing_code":" beginPermissionVerification: () => {\n // 后台刷新时保留已就绪的权限,避免界面闪加载\n if (get().status === 'ready') return;"}
{"path":"apps/admin/src/store/permission/permissionStore.ts","start_line":29,"end_line":31,"category":"bug","severity":"low","content":"Stale-response race in `writePermissions`: it unconditionally sets `status: 'ready'` with whatever payload arrives, without correlating the response to the current verification cycle. If an older in-flight verification resolves after a newer one (or after a logout that called `clearPermissions`), the stale response re-marks the store as 'ready' with outdated permissions — e.g., a slow background-refresh response landing after logout resurrects the old permissions. Consider tracking a generation/request counter (incremented in `beginPermissionVerification`/`clearPermissions`) and ignoring `writePermissions` results whose token is older than the current one.","suggestion_code":null,"existing_code":" writePermissions: (permissions) => {\n set(\n { permissions: [...new Set(permissions)], status: 'ready' },"}
{"path":"apps/admin/src/utils/sensitive.ts","start_line":2,"end_line":2,"category":"security","severity":"medium","content":"Privacy/correctness issue: when the input is shorter than the threshold, the raw unmasked value is returned (or the value is only trivially masked). For a 7-char phone, `slice(0,3)` + `slice(-4)` reveals all 7 digits with asterisks inserted in the middle, and a 6-digit value is returned in full — defeating the purpose of masking sensitive PII. Consider returning a fully masked fallback (e.g., `'****'` or `'-'`) for values below the threshold and requiring a length that guarantees a meaningful hidden portion.","suggestion_code":null,"existing_code":"if (!phone || phone.length < 7) return phone || '-';"}
{"path":"apps/admin/src/utils/sensitive.ts","start_line":6,"end_line":9,"category":"maintainability","severity":"low","content":"This function duplicates `maskPhone` almost exactly (same guard + slice pattern, only threshold and mask length differ). Consider extracting a generic helper, e.g. `maskValue(value: string, minLen: number, head: number, tail: number, mask: string)`, and having both exports delegate to it to avoid divergent behavior in the future.","suggestion_code":null,"existing_code":"export const maskIdNumber = (id: string): string => {\n if (!id || id.length < 8) return id || '-';\n return id.slice(0, 3) + '***********' + id.slice(-4);\n};"}
{"path":"apps/admin/src/utils/sensitive.ts","start_line":8,"end_line":8,"category":"bug","severity":"medium","content":"The mask uses a fixed 11 asterisks regardless of the actual input length. For IDs shorter than 18 digits, the masked output becomes longer than the original (e.g., an 8-char ID becomes 18 chars) and the hidden portion is not proportional. Also, for an 8-char ID, only 1 character is actually hidden (first 3 + last 4 = 7 of 8), so the ID is effectively exposed. Prefer building the mask from the actual hidden length (e.g., repeat `'*'` by `id.length - head - tail`) to keep the output length consistent and hide a meaningful portion.","suggestion_code":null,"existing_code":"return id.slice(0, 3) + '***********' + id.slice(-4);"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":73,"end_line":84,"category":"bug","severity":"medium","content":"Data-loss risk: `adapter.clearLegacy()` runs unconditionally even when `localStorage.setItem` throws (e.g., QuotaExceededError / storage disabled). The old key is the only copy of the session (token/user or dock tabs) before migration, so a failed write during the migrate write-back permanently destroys the legacy data — on the next page load the user is logged out with no way to recover. `removeItem` has the same problem. Only clear the legacy key after the new-key operation has succeeded.","suggestion_code":" setItem: (name, value) => {\n try {\n localStorage.setItem(name, value);\n adapter.clearLegacy();\n } catch {\n // 持久化失败不应影响应用运行,同时保留旧 key 以便下次重试\n }\n },","existing_code":" setItem: (name, value) => {\n try {\n localStorage.setItem(name, value);\n } catch {\n // 持久化失败不应影响应用运行\n }\n try {\n adapter.clearLegacy();\n } catch {\n // 清理失败不影响应用运行\n }\n },"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":46,"end_line":47,"category":"bug","severity":"low","content":"Inconsistent robustness: the write paths (`setItem`/`removeItem`) are wrapped in try/catch, but the read paths (`localStorage.getItem` in `readLegacyAuth`, `legacyDockValue`, `legacyFallbackStorage.getItem` and `permissionStorage.getItem`) are not. In restricted environments (storage disabled, privacy mode) `localStorage.getItem` can throw SecurityError, which would propagate out of the storage's `getItem` and break store initialization — the opposite of the stated intent that persistence failures should not affect the app. Guard the reads as well.","suggestion_code":" let token: string | null = null;\n let user: UserInfo | null = null;\n try {\n token = localStorage.getItem(LEGACY_TOKEN_KEY);\n const rawUser = localStorage.getItem(LEGACY_USER_KEY);\n if (rawUser !== null) {\n try {\n const parsed: unknown = JSON.parse(rawUser);\n user = isRecord(parsed) ? (parsed as UserInfo) : null;\n } catch {\n user = null;\n }\n }\n } catch {\n // 读取失败不应影响应用运行\n }\n return { token, user };","existing_code":" const token = localStorage.getItem(LEGACY_TOKEN_KEY);\n const rawUser = localStorage.getItem(LEGACY_USER_KEY);"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":228,"end_line":228,"category":"bug","severity":"low","content":"Inconsistent validation between the two branches of `migrateAppUiState`: the v1-envelope path filters `routeDockTabs` with `isDockTab`, but the partial-state path returns the array as-is. A corrupted/older partial state could hydrate invalid tabs (no leading '/', missing label, or containing '?') into the store and break the dock UI. Apply the same `isDockTab` filter here.","suggestion_code":" routeDockTabs: Array.isArray(existing.routeDockTabs) ? existing.routeDockTabs.filter(isDockTab) : [],","existing_code":" routeDockTabs: Array.isArray(existing.routeDockTabs) ? existing.routeDockTabs : [],"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":190,"end_line":191,"category":"bug","severity":"low","content":"Inconsistent validation in `migrateAuthState`: the envelope branch verifies `state.user` with `isRecord(...)` before casting, but the partial-state branch assigns `existing.user` directly without any shape check. Since `existing` is typed via `as Partial<UserPersistedState>`, a garbage value (string/number/array) from storage would be trusted as a `UserInfo` and could crash consumers that read `user.id`/`user.username`. Mirror the envelope branch's `isRecord` check.","suggestion_code":" token: existing.token ?? null,\n user: isRecord(existing.user) ? (existing.user as UserInfo) : null,","existing_code":" token: existing.token ?? null,\n user: existing.user ?? null,"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":103,"end_line":104,"category":"security","severity":"low","content":"Security consideration: the auth token is persisted to `localStorage` (via `authPersistStorage`). Any XSS in the app can trivially read it, which fully compromises the session. If feasible for this app, prefer storing the token in an httpOnly cookie (or short-lived memory + refresh flow); at minimum keep this risk in mind and ensure no `dangerouslySetInnerHTML`/`eval`-style sinks exist anywhere in the admin app.","suggestion_code":null,"existing_code":" if (token === null && user === null) return null;\n return JSON.stringify({ state: { token, user }, version: 1 });"}
{"path":"apps/admin/src/store/middleware/persist.ts","start_line":180,"end_line":181,"category":"maintainability","severity":"low","content":"Duplicated logic: the three migrate functions (`migrateAuthState`, `migratePermissionState`, `migrateAppUiState`) all re-implement the same \"envelope vs. partial state\" branching (`isRecord(persisted) && isRecord(persisted.state)` then `Array.isArray(...)` fallback), and the storage adapters share the same try/catch-laden set/remove pattern. Consider extracting small helpers (e.g., `unwrapEnvelope(persisted)` / a `safeStorage` wrapper) to keep future migration additions consistent.","suggestion_code":null,"existing_code":"export function migrateAuthState(persisted: unknown, _version: number): UserPersistedState {\n if (isRecord(persisted) && isRecord(persisted.state)) {"}
{"path":"apps/admin/src/utils/validate.ts","start_line":7,"end_line":7,"category":"maintainability","severity":"medium","content":"Type-safety issue: `schema: z.ZodType<unknown>` erases the schema's output type, and the unconstrained generic `T` combined with the `as T` cast means callers can pass a schema/T pair that don't match (e.g. `validateResponse<number>(z.string(), data)`) without any compile error, risking runtime type mismatches. Tie the generic to the schema's output type and drop the unsafe cast: `schema: z.ZodType<T>` and `return result.data;` (result.data will then already be typed as `T`).","suggestion_code":"export function validateResponse<T>(schema: z.ZodType<T>, data: unknown): T {","existing_code":"export function validateResponse<T>(schema: z.ZodType<unknown>, data: unknown): T {"}
{"path":"apps/admin/src/utils/validate.ts","start_line":10,"end_line":11,"category":"maintainability","severity":"low","content":"Only the first issue's path is surfaced in the thrown error message. When several fields fail validation at once, users only see one field, which can be misleading for debugging. Since all issues are already available in `result.error.issues`, consider aggregating the paths (e.g. `issues.map(i => i.path.join('.')).join(', ')`) so the message reports all malformed fields.","suggestion_code":null,"existing_code":" const first = result.error.issues[0];\n const path = first?.path?.join('.');"}
{"path":"apps/admin/src/components/AiChat/ArtifactErrorBoundary.tsx","start_line":32,"end_line":33,"category":"maintainability","severity":"low","content":"Error boundary state is never reset: once an artifact throws, `hasError` stays true for the lifetime of this component instance. The fallback description tells users to \"refresh and retry\", but the component offers no retry/reset path. Call sites currently pass `key={id}` (see AiMessageContent.tsx), which remounts the boundary when the artifact id changes, but if the same artifact id re-renders successfully later (e.g. streaming/regeneration updates), the stale error placeholder persists and permanently masks the recovered content. Consider exposing a reset mechanism (e.g. resetting `hasError` when `children` changes via `getDerivedStateFromProps`/`componentDidUpdate`, or documenting that callers MUST use a stable per-artifact `key`).","suggestion_code":null,"existing_code":" render(): React.ReactNode {\n if (this.state.hasError) {"}
{"path":"apps/admin/src/App.tsx","start_line":345,"end_line":346,"category":"bug","severity":"medium","content":"There is no catch-all route. Any unknown/typo'd path (e.g. /studnets) matches no route under the \"/\" layout, so react-router renders nothing and the user gets a blank page with no feedback. Add a `path=\"*\"` route (redirect to \"/\" or show a 404 Result) so unknown URLs are handled gracefully.","suggestion_code":" </Route>\n <Route path=\"*\" element={<Navigate to=\"/\" replace />} />\n </Routes>","existing_code":" </Route>\n </Routes>"}
{"path":"apps/admin/src/App.tsx","start_line":48,"end_line":48,"category":"bug","severity":"low","content":"When unauthenticated, the redirect to /login is performed without `replace`, which pollutes the history stack: pressing the browser Back button after login returns to the previously protected URL and immediately bounces to /login again. Use `replace` so the protected route is not kept in history.","suggestion_code":" return token ? <>{children}</> : <Navigate to=\"/login\" replace />;","existing_code":" return token ? <>{children}</> : <Navigate to=\"/login\" />;"}
{"path":"apps/admin/src/App.tsx","start_line":162,"end_line":169,"category":"maintainability","severity":"low","content":"Every route's permission string is hardcoded inline here (~30 occurrences), duplicating the single-source-of-truth tables in auth/menu-policy.ts and auth/permission-navigation.ts. This duplication has already drifted: the /wallets route exists here (wallet:view) and in the menu policy, but is missing from PERMISSION_PAGES in permission-navigation.ts, so canAccessPath('/wallets') returns true for all logged-in users regardless of the wallet:view gate. Consider deriving the route table from the central permission config to keep the two in sync.","suggestion_code":null,"existing_code":" <Route\n path=\"wallets\"\n element={\n <PermissionRoute permission=\"wallet:view\">\n <WalletsPage />\n </PermissionRoute>\n }\n />"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx","start_line":42,"end_line":47,"category":"style","severity":"low","content":"This is a nested ternary chain, which is explicitly prohibited by the project's review rules. Extract it into a small lookup map or helper function to improve readability and reduce the risk of future branch mistakes.","suggestion_code":" status: attachmentUploadStatus[attachment.status],\n // or: status: toUploadStatus(attachment.status),","existing_code":" status:\n attachment.status === 'ready'\n ? 'done'\n : attachment.status === 'failed'\n ? 'error'\n : 'uploading',"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx","start_line":76,"end_line":80,"category":"bug","severity":"medium","content":"The loop returns the first numeric replyToMessageId found after the user message without verifying it actually refers to THIS message. If the user sends two pending messages before the assistant replies (message B followed by message A), the first assistant reply's replyToMessageId belongs to message B, so resolving message A would return message B's server id — later deleting/editing the wrong message. Consider validating the mapping (e.g., only accept the reply whose replyToMessageId matches this message's own assigned id, or bail out when another pending user message precedes the reply) and add a comment documenting the assumption.","suggestion_code":null,"existing_code":" for (const item of all.slice(index + 1)) {\n if (typeof item.message.replyToMessageId === 'number') {\n return item.message.replyToMessageId;\n }\n }"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.helpers.tsx","start_line":27,"end_line":28,"category":"bug","severity":"low","content":"If a conversation's lastMessageAt/updatedAt contains an unparsable value, new Date(...).getTime() returns NaN and the comparator produces NaN, leaving the sort order unstable/implementation-defined. Guard against invalid dates (e.g., fall back to 0) so the sort remains deterministic.","suggestion_code":" const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime() || 0;\n const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime() || 0;","existing_code":" const aTime = new Date(a.lastMessageAt || a.updatedAt).getTime();\n const bTime = new Date(b.lastMessageAt || b.updatedAt).getTime();"}
{"path":"apps/admin/src/components/AiChat/AiMessageContent.tsx","start_line":138,"end_line":138,"category":"style","severity":"low","content":"Nested ternaries are prohibited by the review rules. The `status`, `content`, and `icon` fields here each chain nested `?:` expressions, which hurts readability and is error-prone (e.g., `running ? ... : success ? ... : ...`). Extract a small helper (e.g., a function that maps `tool.status` to `{ status, icon, content }`) or use explicit if/else branches.","suggestion_code":null,"existing_code":" status: running ? 'loading' : success ? 'success' : 'error',"}
{"path":"apps/admin/src/components/AiChat/AiMessageContent.tsx","start_line":96,"end_line":96,"category":"maintainability","severity":"low","content":"`openAttachment` and `openSourceUrl` are near-duplicate implementations (fetch with auth header → blob → open in new tab → delayed revoke). Extract a shared helper such as `openProtectedUrl(url, errorMessage)` to avoid future drift between the two code paths.","suggestion_code":null,"existing_code":"async function openSourceUrl(item: { url?: string }): Promise<void> {"}
{"path":"apps/admin/src/components/AiChat/AiMessageContent.tsx","start_line":87,"end_line":89,"category":"security","severity":"medium","content":"The user's bearer token is attached to `fetch(url)` without verifying the URL is same-origin. `attachment.url` and `a2uiSources[].url` come from server/AI-generated data; if any source URL is external, the token would be sent to a third party (credential leak). The same issue exists in `openSourceUrl`. Validate the origin (e.g., `new URL(url, window.location.origin).origin === window.location.origin`) before adding the `Authorization` header.","suggestion_code":null,"existing_code":" const response = await fetch(attachment.url, {\n headers: token ? { Authorization: `Bearer ${token}` } : undefined,\n });"}
{"path":"apps/admin/src/components/AiChat/AiMessageContent.tsx","start_line":230,"end_line":230,"category":"performance","severity":"low","content":"`deriveForms(message)` is invoked twice per render (same for `deriveReviews`/`deriveCharts` below), and the whole expression is re-evaluated on every render including each streaming SSE chunk. Call the derive function once into a local variable before choosing the fallback, e.g. `const derived = deriveForms(message); const forms = derived.length > 0 ? derived : (message.forms ?? []);`","suggestion_code":null,"existing_code":" const forms = deriveForms(message).length > 0 ? deriveForms(message) : (message.forms ?? []);"}
{"path":"apps/admin/src/components/AiChat/AiMessageContent.tsx","start_line":93,"end_line":93,"category":"bug","severity":"low","content":"`window.open` can return `null` when the popup is blocked by the browser; in that case the click silently does nothing even though the code explicitly aims to avoid \"clicked but no response\". Also, revoking the object URL after a fixed 60s can abort the download of large attachments if the new tab is still loading. Consider checking the `window.open` result (fall back to `message.warning` or a direct download) and revoking after the tab loads rather than a fixed timeout.","suggestion_code":null,"existing_code":" window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);"}
{"path":"apps/admin/src/components/AiChat/DynamicChart.tsx","start_line":204,"end_line":208,"category":"bug","severity":"high","content":"The empty/undefined data guard runs *after* `useMemo` has already invoked `buildOption(chart)`. Every builder (`buildNameValueRows`, `buildCategoryOption`, etc.) immediately calls `chart.rows.map(...)` and `chart.columns[0]?.key`, so if `rows`/`columns` is missing at runtime this throws before the \"暂无数据\" placeholder is reached. The chart payload comes from AI message metadata that is only validated as `typeof payload === 'object'` (see `deriveCharts` in uiArtifacts.ts), so a missing `rows` array is entirely possible and the `!chart.rows` guard itself acknowledges it — the guard is just ineffective because of ordering. Fix: make the option build null-safe instead of moving the guard above the hook (that would violate the Rules of Hooks), e.g. only build the option when rows exist, and guard `columns` in the builders.","suggestion_code":" const option = useMemo<EChartsOption>(\n () => (chart && chart.rows?.length ? buildOption(chart) : {}),\n [chart],\n );\n const [instance, setInstance] = useState<EChartsType | null>(null);\n if (!chart) return null;\n // 空数据集:渲染明确占位,而不是一张空白图\n if (!chart.rows || chart.rows.length === 0) {","existing_code":" const option = useMemo<EChartsOption>(() => (chart ? buildOption(chart) : {}), [chart]);\n const [instance, setInstance] = useState<EChartsType | null>(null);\n if (!chart) return null;\n // 空数据集:渲染明确占位,而不是一张空白图\n if (!chart.rows || chart.rows.length === 0) {"}
{"path":"apps/admin/src/components/AiChat/DynamicChart.tsx","start_line":211,"end_line":214,"category":"maintainability","severity":"low","content":"The chart-card header JSX (title + type tag) is duplicated verbatim in the empty-state branch and the normal branch. Extract it into a small local render (e.g. `renderHeader()`) or a sub-component to avoid the two branches drifting apart when the header changes.","suggestion_code":null,"existing_code":" <div className=\"ai-chat-chart-card__header\">\n <Typography.Text strong>{chart.title}</Typography.Text>\n <Tag color=\"blue\">{CHART_TYPE_LABELS[chart.chartType] ?? chart.chartType}</Tag>\n </div>"}
{"path":"apps/admin/src/components/AiChat/DynamicChart.tsx","start_line":215,"end_line":222,"category":"style","severity":"low","content":"Static inline styles are used for the empty-state placeholder block (height/flex/align). Per the review rules, inline `style` should be reserved for dynamic values; move these fixed layout styles into the CSS module/class (e.g. a `ai-chat-chart-card__empty` class).","suggestion_code":null,"existing_code":" <div\n style={{\n height: 120,\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n }}\n >"}
{"path":"apps/admin/src/components/AiChat/LiteMermaid.tsx","start_line":21,"end_line":23,"category":"bug","severity":"medium","content":"Race condition: the effect has no cancellation/cleanup for the in-flight async render. If `children` changes while a previous `mermaid.render` is still pending (common with streaming AI responses or quick message switches — `mermaid` is dynamically imported and slow on first load), the older render can complete after the newer one and overwrite `container` with stale SVG, or a stale failure can set `error` for the new content. `isMounted()` only guards against unmount, not against stale async results. Fix: add a per-effect `cancelled` flag reset in the effect cleanup and check it before touching DOM/state.","suggestion_code":" useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n let cancelled = false;\n\n void (async () => {\n try {\n const mermaid = (await import('mermaid')).default;\n mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });\n const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);\n if (cancelled) return;\n const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');\n if (doc.querySelector('parsererror')) throw new Error('Invalid SVG output');\n container.replaceChildren(doc.documentElement);\n setError(null);\n } catch (e) {\n if (!cancelled) {\n setError(e instanceof Error ? e.message : '图表渲染失败');\n }\n }\n })();\n\n return () => {\n cancelled = true;\n };\n }, [children]);","existing_code":" void (async () => {\n try {\n const mermaid = (await import('mermaid')).default;"}
{"path":"apps/admin/src/components/AiChat/LiteMermaid.tsx","start_line":25,"end_line":25,"category":"bug","severity":"low","content":"`crypto.randomUUID()` is only available in secure contexts (HTTPS/localhost) and modern browsers; on HTTP or older environments it throws `TypeError: crypto.randomUUID is not a function`, which is caught and surfaced as a render failure even though the diagram itself is valid. Consider a fallback (e.g., `crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)`) or a module-level counter for the render id.","suggestion_code":null,"existing_code":" const { svg } = await mermaid.render(`mermaid-${crypto.randomUUID()}`, children);"}
{"path":"apps/admin/src/components/AiChat/LiteMermaid.tsx","start_line":27,"end_line":28,"category":"bug","severity":"low","content":"The DOMParser result is inserted without checking for a `parsererror` element. If `mermaid.render` ever returns malformed/non-SVG markup, `doc.documentElement` will be a `<parsererror>` node and the user will see raw parser error text instead of a graceful fallback. Check `doc.querySelector('parsererror')` and throw/report it instead.","suggestion_code":null,"existing_code":" const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');\n container.replaceChildren(doc.documentElement);"}
{"path":"apps/admin/src/components/AiChat/DynamicForm.tsx","start_line":107,"end_line":110,"category":"bug","severity":"medium","content":"antd `Form` only applies `initialValues` on mount. Because the surface id is derived from `form.id`, a schema update (new fields/defaults) or a regenerated form reusing the same `id` will leave the mounted `Form` with stale user-entered values — the memoized `initialValues` change is silently ignored. Key the `Form` by a stable schema revision (e.g. `form.id`, or include a schema hash) so it remounts when the underlying schema changes.","suggestion_code":" <Form\n key={`${form.id}:${JSON.stringify(form.fields)}`}\n layout=\"vertical\"\n size=\"small\"\n initialValues={initialValues}","existing_code":" <Form\n layout=\"vertical\"\n size=\"small\"\n initialValues={initialValues}"}
{"path":"apps/admin/src/components/AiChat/DynamicForm.tsx","start_line":217,"end_line":217,"category":"performance","severity":"medium","content":"This effect depends on the entire `form` object and re-pushes the full command batch (`updateDataModel` + `updateComponents`) every time it runs. `pushCommands` only dedupes `createSurface`, so if the parent recreates the `form` object on each render, duplicate commands accumulate unboundedly in the surface command list and are replayed by XCard. Consider memoizing `form` in the parent, depending on stable keys (`form.id` / `form.fields`), or skipping the push when the serialized payload is unchanged.","suggestion_code":" // Avoid depending on the whole `form` object identity: if the parent rebuilds it\n // each render, every render pushes duplicate commands. Use stable keys instead.\n }, [disabled, error, form?.id, form?.fields, pushCommands, sid, submitted, submitting]);","existing_code":" }, [disabled, error, form, pushCommands, sid, submitted, submitting]);"}
{"path":"apps/admin/src/components/AiChat/DynamicForm.tsx","start_line":131,"end_line":138,"category":"style","severity":"low","content":"This is a nested/chained ternary expression (textarea → number → select → date → default), which is disallowed by the review rules. Extract a `renderField(field)` helper function so each branch is a plain `if/else` (or switch), improving readability.","suggestion_code":null,"existing_code":" {field.type === 'textarea' ? (\n <Input.TextArea rows={3} placeholder={field.placeholder} />\n ) : field.type === 'number' ? (\n <InputNumber\n className=\"ai-chat-dynamic-form__number\"\n placeholder={field.placeholder}\n />\n ) : field.type === 'select' ? ("}
{"path":"apps/admin/src/components/AiChat/DynamicForm.tsx","start_line":227,"end_line":231,"category":"bug","severity":"low","content":"When `form:submit` arrives without a valid `values` context, the handler silently submits an empty object `{}` to `onSubmit`. A malformed payload should be ignored (with a warning) rather than triggering a submission that may pass server-side validation with missing required fields.","suggestion_code":" const values =\n payload.context?.values && typeof payload.context.values === 'object'\n ? (payload.context.values as Record<string, unknown>)\n : null;\n if (!values) return;\n void handleSubmit(values);","existing_code":" const values =\n payload.context?.values && typeof payload.context.values === 'object'\n ? (payload.context.values as Record<string, unknown>)\n : {};\n void handleSubmit(values);"}
{"path":"apps/admin/src/components/AiChat/DynamicForm.tsx","start_line":184,"end_line":184,"category":"bug","severity":"low","content":"`submitting`/`submitted`/`error` are local to this component instance and never reset when `form.id` changes. If the parent reuses the same `DynamicForm` instance for a new form id (e.g. reconciliation without a `key`), the new form would immediately render the previous submission state (spinner/success alert) instead of a fresh form. Consider resetting state when `form.id` changes (e.g. via `useEffect` on `form.id` calling `reset`) or ensuring the parent keys each instance by form id.","suggestion_code":null,"existing_code":" const { submitting, submitted, error, run } = useSubmissionState();"}
{"path":"apps/admin/src/components/AppErrorBoundary.tsx","start_line":33,"end_line":35,"category":"bug","severity":"medium","content":"`hasError` is set once and never reset: after a single render error the fallback is shown permanently, even after the error condition disappears. This matters here because RouteKeeper wraps each cached page with this boundary — a transient error would permanently replace that page's content (and the global wrapper in main.tsx would permanently block the app) until a full page reload. Suggest resetting state when `children` changes (e.g. in `componentDidUpdate`, compare `this.props.children !== prevProps.children`) or exposing a retry action that calls `setState({ hasError: false })` instead of forcing `window.location.reload()`.","suggestion_code":null,"existing_code":" if (this.state.hasError) {\n if (this.props.fallback) return this.props.fallback;\n return ("}
{"path":"apps/admin/src/components/AppErrorBoundary.tsx","start_line":29,"end_line":29,"category":"maintainability","severity":"low","content":"`componentDidCatch` only logs to the console, so render failures are invisible to any error monitoring in production. Consider reporting `error` and `info.componentStack` to an error tracking service here (note also that error boundaries cannot catch errors thrown in event handlers, async callbacks, or SSR, so those paths need separate handling).","suggestion_code":null,"existing_code":" console.error('[AppErrorBoundary] 渲染异常:', error, info.componentStack);"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":430,"end_line":431,"category":"bug","severity":"medium","content":"Writing to a ref during render is discouraged by React (unsafe with concurrent rendering/StrictMode double render — the committed render can be discarded while the ref is left with a value from an abandoned render). Move the assignment into an effect declared before the `[review]` sync effect so `activeTypeRef` is guaranteed to hold the committed value when read.","suggestion_code":" const activeTypeRef = useRef<AiReviewSectionType | undefined>(undefined);\n\n useEffect(() => {\n activeTypeRef.current = activeType;\n }, [activeType]);","existing_code":" const activeTypeRef = useRef<AiReviewSectionType | undefined>(activeType);\n activeTypeRef.current = activeType;"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":129,"end_line":136,"category":"maintainability","severity":"low","content":"Nested ternary expressions are prohibited by the review rules. The `stepStatus` chain (four levels) should be replaced with a lookup table or an extracted helper function for readability.","suggestion_code":" const stepStatus: 'finish' | 'error' | 'process' | 'wait' = (() => {\n if (status === 'submitted') return 'finish';\n if (status === 'failed') return 'error';\n if (status === 'importing' || type === activeType) return 'process';\n return 'wait';\n })();","existing_code":" const stepStatus: 'finish' | 'error' | 'process' | 'wait' =\n status === 'submitted'\n ? 'finish'\n : status === 'failed'\n ? 'error'\n : status === 'importing' || type === activeType\n ? 'process'\n : 'wait';"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":302,"end_line":310,"category":"maintainability","severity":"low","content":"Nested ternary expressions are prohibited by the review rules. This 5-level chain (failed/submitted/skipped/expired/default) should be extracted into a helper function or a lookup map for clarity.","suggestion_code":null,"existing_code":" {status === 'failed'\n ? '重试导入本步'\n : status === 'submitted'\n ? '已导入'\n : status === 'skipped'\n ? '已跳过'\n : expired\n ? '已失效'\n : '确认导入本步'}"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":508,"end_line":510,"category":"bug","severity":"medium","content":"Duplicate-submission guards rely on component state (`if (submitting) return`), which is stale within the same event tick — two rapid invocations can both pass the guard and fire the import twice, and `handleConfirmStep`/`handleConfirmGroup`/`handleSubmit` do not guard against each other either. The sibling `useSubmissionState` already solves this with a `submittingRef`; apply the same ref-based guard here (reset in `finally`) for robustness.","suggestion_code":" const submittingRef = useRef(false);\n const handleSubmit = async (reviewId: string) => {\n if (submittingRef.current) return;\n submittingRef.current = true;\n setSubmitting(true);\n setError(null);\n try {\n await onSubmit(reviewId);\n } catch (reason) {\n setError(errorMessage(reason));\n } finally {\n submittingRef.current = false;\n setSubmitting(false);\n }\n };","existing_code":" const handleSubmit = async (reviewId: string) => {\n if (submitting) return;\n setSubmitting(true);"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":437,"end_line":438,"category":"bug","severity":"low","content":"When the `review` prop is replaced with a new review, `error` is not reset, so an error from the previous review's failed action leaks into the new card (both in the card data model and the footer Alert). Clear `error` here alongside the other state resets.","suggestion_code":" useEffect(() => {\n setLocalReview(review);\n setError(null);","existing_code":" useEffect(() => {\n setLocalReview(review);"}
{"path":"apps/admin/src/components/AiChat/DynamicReview.tsx","start_line":279,"end_line":279,"category":"style","severity":"low","content":"Static inline style — per the review rules inline styles should only be used for dynamic values. `minWidth: 160` is constant; move it to a CSS class.","suggestion_code":null,"existing_code":" <Flex vertical gap={2} style={{ minWidth: 160 }}>"}
{"path":"apps/admin/src/components/BrandLogo.tsx","start_line":8,"end_line":20,"category":"maintainability","severity":"low","content":"Most of these style declarations are static (display, alignItems, justifyContent, borderRadius, background, color, flexShrink) and are mixed with the dynamic size-dependent values. Per the project's rule of avoiding inline `style` except for dynamic styles, the static properties should be extracted into a CSS class, keeping only `width`, `height`, and `fontSize` inline.","suggestion_code":null,"existing_code":" <span\n style={{\n display: 'inline-flex',\n alignItems: 'center',\n justifyContent: 'center',\n width: size,\n height: size,\n borderRadius: 8,\n background: BRAND_COLOR,\n color: '#fff',\n flexShrink: 0,\n }}\n >"}
{"path":"apps/admin/src/components/BrandLogo.tsx","start_line":21,"end_line":21,"category":"other","severity":"low","content":"The brand logo container provides no accessible name. Since the element is a purely decorative icon driven by `size` and color, consider adding `role=\"img\"` with an `aria-label` (e.g. the brand name) or `aria-hidden=\"true\"` if it is purely decorative, so assistive technologies interpret it correctly.","suggestion_code":null,"existing_code":" <ReadOutlined style={{ fontSize: size * 0.55 }} />"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":97,"end_line":97,"category":"maintainability","severity":"medium","content":"Writing to a ref during render is a render side effect, which is discouraged by React (under concurrent rendering the write may be based on a discarded render, and it also makes the component non-pure). Since this ref is only consumed inside async callbacks (`refreshConversations`), move the sync into a `useEffect` so it always reflects the committed value.","suggestion_code":" useEffect(() => {\n activeConversationKeyRef.current = activeConversationKey;\n }, [activeConversationKey]);","existing_code":" activeConversationKeyRef.current = activeConversationKey;"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":99,"end_line":101,"category":"bug","severity":"medium","content":"`refreshConversations` has no try/catch. It is invoked via `void refreshConversations()` from the provider completion callback (and from `useAiChatMessageActions` on message deletion), so a failed `listConversations()` produces an unhandled promise rejection with no user feedback. Wrap the call and surface a friendly error.","suggestion_code":" const refreshConversations = useCallback(async () => {\n let items: ConversationData[];\n try {\n items = sortConversations(await aiChatApi.listConversations()).map(toConversationData);\n } catch {\n message.error('刷新会话列表失败');\n return;\n }\n setConversations(items);","existing_code":" const refreshConversations = useCallback(async () => {\n const items = sortConversations(await aiChatApi.listConversations()).map(toConversationData);\n setConversations(items);"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":103,"end_line":105,"category":"bug","severity":"medium","content":"When the current key is the draft state `''`, this fallback forcibly switches the active conversation to `items[0]`. Because background conversation completions also call `refreshConversations`, a user composing a new draft while another conversation finishes will be yanked out of the draft, and the composed text would then be sent to that other conversation. Preserve the `''` draft state explicitly.","suggestion_code":" let nextKey = items[0]?.key ?? '';\n if (current && items.some((item) => item.key === current)) nextKey = current;\n else if (current === '') nextKey = '';\n setActiveConversationKey(nextKey);","existing_code":" setActiveConversationKey(\n current && items.some((item) => item.key === current) ? current : (items[0]?.key ?? ''),\n );"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":349,"end_line":357,"category":"bug","severity":"medium","content":"In the delete-all branch, abort handles / providers / status are cleared before `deleteAllConversations()` resolves. If the API call fails, the confirm's `onOk` rejects (modal stays open) but the local runtime state has already been wiped while the conversations remain in the list — inconsistent state, and no error message is shown. Await the API call first and handle the failure (e.g. keep selection and show an error) before clearing local state.","suggestion_code":" if (selected.length === conversations.length) {\n try {\n await aiChatApi.deleteAllConversations();\n } catch {\n message.error('删除会话失败,请重试');\n return;\n }\n for (const abort of requestAbortRef.current.values()) abort();\n requestAbortRef.current.clear();\n providersRef.current.clear();\n setConversationStatus({});\n setConversations([]);\n switchConversation('');\n } else {","existing_code":" if (selected.length === conversations.length) {\n for (const abort of requestAbortRef.current.values()) abort();\n requestAbortRef.current.clear();\n providersRef.current.clear();\n setConversationStatus({});\n await aiChatApi.deleteAllConversations();\n setConversations([]);\n switchConversation('');\n } else {"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":459,"end_line":463,"category":"maintainability","severity":"low","content":"The Checkbox is controlled (`checked`) but has no `onChange`, making it effectively read-only and triggering a React controlled-input warning. Selection toggling currently relies solely on the row click. Wire the checkbox up and stop click propagation so clicking the checkbox toggles reliably without double-firing the row's `onActiveChange`.","suggestion_code":" <span\n className=\"ai-chat-conversation-check\"\n onClick={(event) => event.stopPropagation()}\n >\n <Checkbox\n checked={selectedKeys.includes(item.key)}\n onChange={() => toggleConversationSelection(item.key)}\n aria-label={`选择 ${item.title}`}\n />\n </span>","existing_code":" <Checkbox\n checked={selectedKeys.includes(item.key)}\n className=\"ai-chat-conversation-check\"\n aria-label={`选择 ${item.title}`}\n />"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.tsx","start_line":377,"end_line":377,"category":"style","severity":"low","content":"`activeId` is typed `number | null`, so per project rules use strict equality instead of `!=`.","suggestion_code":" } else if (activeId !== null && !remaining.some((item) => item.id === activeId)) {","existing_code":" } else if (activeId != null && !remaining.some((item) => item.id === activeId)) {"}
{"path":"apps/admin/src/components/DefaultRoute.tsx","start_line":10,"end_line":10,"category":"bug","severity":"medium","content":"The selector `(state) => state.user?.roles ?? []` returns a fresh `[]` literal every time it is evaluated whenever `state.user` is null (e.g. logged-out / not-yet-loaded session). Since `useUserStore` is a zustand store, its underlying `useSyncExternalStore` `getSnapshot` is compared with `Object.is`; a new array reference on each call violates the stable-snapshot contract and can trigger the \"getSnapshot should be cached\" warning / an infinite re-render loop, and at minimum causes needless re-renders on every store update. Select the raw value and apply the fallback outside the selector instead.","suggestion_code":"const roles = useUserStore((state) => state.user?.roles) ?? [];","existing_code":"const roles = useUserStore((state) => state.user?.roles ?? []);"}
{"path":"apps/admin/src/components/DefaultRoute.tsx","start_line":12,"end_line":12,"category":"maintainability","severity":"low","content":"Static (non-dynamic) inline styles are discouraged by the review rules. Move this fixed layout style into a CSS class so it can be reused and theme-managed.","suggestion_code":"return <Spin size=\"large\" className=\"default-route-spin\" />;","existing_code":"return <Spin size=\"large\" style={{ display: 'block', margin: '80px auto' }} />;"}
{"path":"apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx","start_line":38,"end_line":38,"category":"performance","severity":"medium","content":"Syntax highlighting with refractor/prism is CPU-intensive, and in an AI chat the parent list re-renders frequently (e.g., streaming updates). Without memoization, every parent render re-runs the tokenization for all code blocks even when `lang`/`children` are unchanged. Since both props are immutable strings here, wrapping the component with React.memo is a cheap, effective optimization.","suggestion_code":"export const LiteCodeHighlighter = memo(function LiteCodeHighlighter({ lang, children }: LiteCodeHighlighterProps) {","existing_code":"export function LiteCodeHighlighter({ lang, children }: LiteCodeHighlighterProps) {"}
{"path":"apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx","start_line":39,"end_line":39,"category":"other","severity":"low","content":"Unsupported languages silently fall back to plain, unhighlighted text. AI responses commonly use python/go/java/html/xml/yaml/markdown as well as alias names like `ts`/`js`/`sh`, all of which will render without any syntax coloring here. Note also that the tsx definition internally auto-registers `jsx`, yet `jsx` is missing from SUPPORTED_LANGUAGES, so JSX-only blocks won't highlight either. Consider adding the most common AI-response languages (including `jsx`) or explicitly documenting the fallback behavior.","suggestion_code":null,"existing_code":"const language = lang && SUPPORTED_LANGUAGES.has(lang) ? lang : undefined;"}
{"path":"apps/admin/src/components/AiChat/LiteCodeHighlighter.tsx","start_line":44,"end_line":44,"category":"style","severity":"low","content":"The `customStyle` here is a static style object. Per the project style rules, static styling should live in CSS (via a className prop) rather than inline styles; reserve inline styles for dynamic values.","suggestion_code":null,"existing_code":"customStyle={{ margin: '12px 0', borderRadius: 8, fontSize: 13 }}"}
{"path":"apps/admin/src/components/BackTop.tsx","start_line":35,"end_line":35,"category":"style","severity":"low","content":"Static inline styles are prohibited by the review rules (inline styles should only be used for dynamic values). These values never change, so extract them into a CSS class (e.g. `.back-top-btn { position: fixed; right: 24px; bottom: 48px; z-index: 1000; }`) for better maintainability and to avoid recreating a new style object on every render.","suggestion_code":" className=\"back-top-btn\"","existing_code":" style={{ position: 'fixed', right: 24, bottom: 48, zIndex: 1000 }}"}
{"path":"apps/admin/src/components/BackTop.tsx","start_line":18,"end_line":18,"category":"performance","severity":"low","content":"Minor performance issue: the inline `{ passive: true }` object literal is recreated on every render, and usehooks-ts' `useEventListener` includes `options` in its internal effect dependencies, so the scroll listener is removed and re-attached on each render of this component. Memoize the options (e.g. `useMemo(() => ({ passive: true }), [])`). Additionally, `setVisible` is invoked on every scroll event; consider rAF-throttling the handler since scroll events can fire at high frequency (React bails out on unchanged values, but the handler still runs every time).","suggestion_code":" useEventListener('scroll', updateVisible, undefined, useMemo(() => ({ passive: true }), []));","existing_code":" useEventListener('scroll', updateVisible, undefined, { passive: true });"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx","start_line":210,"end_line":213,"category":"bug","severity":"medium","content":"The footer `Attachments` is always controlled with `items={[]}` and no `onChange`. In `@ant-design/x`, once `items` is provided the component is fully controlled (`useControlledState([], items)`), so files selected here never appear in this instance's file list and upload progress is never shown. The header instance is only rendered when `uploadItems.length > 0`, i.e. only after the upload has already completed (parent state is updated in `customUpload`'s success path). As a result, during the upload the user gets no visual feedback at all (no chip, no progress), and the selected file cannot be removed from the footer. Consider dropping `items={[]}` (let the footer manage its own list) and syncing via `onChange`, or otherwise surfacing the uploading state.","suggestion_code":" <Attachments\n customRequest={onCustomUpload}\n onRemove={onRemoveAttachment}","existing_code":" <Attachments\n items={[]}\n customRequest={onCustomUpload}\n onRemove={onRemoveAttachment}"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx","start_line":201,"end_line":202,"category":"maintainability","severity":"low","content":"The `accept` / `multiple` / `customRequest` / `onRemove` configuration is duplicated verbatim across the two `Attachments` instances (header and footer). Extract a shared constant (e.g. `attachmentAccept`) and/or a shared props object so future changes to accepted file types or upload behavior are made in one place.","suggestion_code":null,"existing_code":" accept=\"image/jpeg,image/png,image/webp,application/pdf,.docx,.xlsx\"\n multiple"}
{"path":"apps/admin/src/components/AiChat/AiChatDrawer.parts.tsx","start_line":70,"end_line":70,"category":"style","severity":"low","content":"Static inline style is used for a fixed font size; per the project rules inline `style` should be reserved for dynamic styles. Move `fontSize: 12` into a CSS class (e.g. `ai-chat-sidebar__empty-hint`).","suggestion_code":" <Typography.Text type=\"secondary\" className=\"ai-chat-sidebar__empty-hint\">","existing_code":" <Typography.Text type=\"secondary\" style={{ fontSize: 12 }}>"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":195,"end_line":198,"category":"bug","severity":"medium","content":"Draft-mode send has no re-entrancy guard. `createConversation()` is async and `isRequesting` is still `false` during that window, so a fast double-click / double-Enter on the send button will run the draft branch twice: two sessions are created server-side, two `queueRequest`s are queued, and the extra session is left orphaned/empty (only one key gets activated). Guard the branch, e.g. reject while `pendingDraftConversationIdRef.current` is non-null (or use a dedicated `submittingDraftRef`), before calling `createConversation`.","suggestion_code":null,"existing_code":" // 草稿态:先创建 session再发送第一条消息\n void (async () => {\n try {\n const created = toConversationData(await aiChatApi.createConversation());"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":131,"end_line":133,"category":"bug","severity":"medium","content":"`requestWithStatus` silently returns when `!activeId || !provider`, but by the time it's called from `submit()` the attachments have already been cleared from both state and `attachmentsRef` and `setInput('')` was called, and from `confirmEditMessage()` the following messages have already been removed locally. If the guard fires (e.g. provider not yet ready), the user's typed message and attachments are lost with no feedback. Either hoist the guard before consuming state in `submit`, or restore the input/attachments and show an error message on this path (mirror the `catch` in the draft branch).","suggestion_code":null,"existing_code":" (params: AiChatInput) => {\n if (!activeId || !provider) return;\n requestAbortRef.current.set(activeId, () => provider.request.abort());"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":437,"end_line":438,"category":"bug","severity":"medium","content":"The 5-attachment limit check is unreliable: `attachmentsRef.current` is only refreshed on re-render (`attachmentsRef.current = attachments`), but `customUpload` only calls `setAttachments(...)` and never updates the ref synchronously. When several files are selected at once, antd Upload invokes `customRequest` per file before a re-render happens, so all of them pass the `>= 5` check and the limit is bypassed. Track the count synchronously (e.g. push to `attachmentsRef.current` on success, decrement on `removeAttachment`/error) or validate against the actual Upload file list. Also consider extracting the magic number `5` into a named constant.","suggestion_code":null,"existing_code":" const file = options.file as File;\n if (attachmentsRef.current.length >= 5) {"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":494,"end_line":495,"category":"style","severity":"low","content":"Nested ternary expressions are not allowed per the review rules. The `extra` expression here nests a second ternary (`editingMessageId === info.id ? undefined : ...`) inside the `role === 'user' ? ... : ...` branch. Extract the hover-action rendering into a small helper function (e.g. `renderUserActions(info)` / `renderAssistantActions(info)`) to flatten the logic.","suggestion_code":null,"existing_code":" info.message.role === 'user' ? (\n editingMessageId === info.id ? undefined : ("}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":121,"end_line":124,"category":"maintainability","severity":"low","content":"These four refs are mutated during the render phase (`requestingRef.current = isRequesting`, etc.). In React 18 concurrent rendering a render pass can be started and then discarded, leaving the refs inconsistent with the committed state, and it violates the \"no side effects in render\" rule. Prefer syncing them inside a `useEffect` (they are only consumed by event handlers/callbacks, so effect timing is fine), or update them at the exact mutation sites (e.g. inside `setAttachments` callbacks and `useXChat` callbacks).","suggestion_code":null,"existing_code":" requestingRef.current = isRequesting;\n abortRef.current = abort;\n attachmentsRef.current = attachments;\n messagesRef.current = messages;"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":104,"end_line":104,"category":"maintainability","severity":"low","content":"The effect assigns `provider.onExternalReview`/`onExternalArtifact` without a cleanup function. When this hook unmounts (or the provider instance changes), the old provider keeps calling closures that reference the stale hook's `setMessage`, which can trigger state updates on an unmounted component and leak handlers. Return a cleanup that clears the handlers (e.g. set them to `undefined`) when the effect re-runs/unmounts.","suggestion_code":null,"existing_code":" provider.onExternalReview = (messageId, review) => {"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":244,"end_line":244,"category":"bug","severity":"low","content":"`navigator.clipboard.writeText` has no error handling: it rejects when the document is not in a secure context or the permission is denied, producing an unhandled promise rejection and silently failing the copy action. Wrap it in try/catch and surface a user-friendly fallback (e.g. `message.error('复制失败')` or a textarea-based fallback).","suggestion_code":null,"existing_code":" void navigator.clipboard.writeText(message.content);"}
{"path":"apps/admin/src/components/AiChat/useAiChatMessageActions.tsx","start_line":417,"end_line":420,"category":"maintainability","severity":"low","content":"The \"merge the returned review into `message.reviews` (replace by id or append)\" logic is duplicated verbatim between `confirmReviewStep` and `confirmReviewGroup` (and again in the `onExternalReview` effect). Extract a single `applyReview(messageId, review)` helper and reuse it in all three places to avoid drift.","suggestion_code":null,"existing_code":" } else if (typeof messageId === 'number') {\n setMessage(messageId, (info) => {\n const reviews = info.message.reviews ?? [];\n const exists = reviews.some((item) => item.id === updated.id);"}
{"path":"apps/admin/src/components/ECharts.tsx","start_line":71,"end_line":74,"category":"bug","severity":"medium","content":"This update effect also runs on the initial mount, immediately after the init effect already called `chart.setOption(optionRef.current)`. So on every mount `setOption` is invoked twice with the same data — and the second call uses `notMerge: true`, which fully rebuilds the chart and discards the first initialization (including anything the `onReady` callback may have done). Additionally, using `notMerge: true` on every subsequent option change wipes interactive state (dataZoom ranges, legend toggles) and restarts animations. Recommend removing the setOption from the init effect (or skipping the first run here) and using the default merge mode (`chart?.setOption(option)`) for normal updates.","suggestion_code":null,"existing_code":" useEffect(() => {\n const chart = containerRef.current ? echarts.getInstanceByDom(containerRef.current) : undefined;\n chart?.setOption(option, true);\n }, [option]);"}
{"path":"apps/admin/src/components/ECharts.tsx","start_line":53,"end_line":56,"category":"maintainability","severity":"low","content":"Writing to refs during render (`optionRef.current = option`, `onReadyRef.current = onReady`) is discouraged by React's rules: in concurrent rendering the render pass can be discarded/replayed, so the ref may not match the committed props, and the mount effect reading `optionRef.current` could consume a stale value. Since the init effect only needs the initial values, just initialize with `useRef(option)`/`useRef(onReady)` and, if the latest value is needed, update the refs inside a `useEffect` instead of during render.","suggestion_code":null,"existing_code":" const optionRef = useRef(option);\n optionRef.current = option;\n const onReadyRef = useRef(onReady);\n onReadyRef.current = onReady;"}
{"path":"apps/admin/src/components/ECharts.tsx","start_line":60,"end_line":62,"category":"bug","severity":"low","content":"`onReady` is invoked with the chart instance created here, but under React 18 StrictMode (dev) effects run mount → cleanup → mount: the first instance is disposed and the callback is invoked again with a brand-new instance. Consumers that captured the first instance (e.g., stored it in state for later export) will hold a disposed chart, and calling `setOption`/`resize` on it logs errors. Consider invoking `onReady` in a way that tolerates remounts, or document that the callback may be called multiple times with a replaced instance.","suggestion_code":null,"existing_code":" const chart = echarts.init(containerRef.current);\n chart.setOption(optionRef.current);\n onReadyRef.current?.(chart);"}
{"path":"apps/admin/src/components/NextStepHint.tsx","start_line":33,"end_line":38,"category":"maintainability","severity":"low","content":"Static (non-dynamic) inline styles are used here: the Card's `style`, the body padding via `styles`, and the icon `color` are all fixed values. Per the review rules, inline `style` attributes should be avoided except for dynamic styles. These colors (`#b7d4ff`, `#f0f7ff`, `#1677ff`) also hardcode theme values that should come from design tokens. Suggest moving them into the `.next-step-hint` CSS class (e.g. via `:global` or a CSS module) so they can be themed and cached consistently.","suggestion_code":"<Card\n size=\"small\"\n className=\"next-step-hint\"\n >","existing_code":"<Card\n size=\"small\"\n className=\"next-step-hint\"\n style={{ marginBottom: 16, borderColor: '#b7d4ff', background: '#f0f7ff' }}\n styles={{ body: { padding: '10px 16px' } }}\n >"}
{"path":"apps/admin/src/components/NextStepHint.tsx","start_line":29,"end_line":30,"category":"bug","severity":"low","content":"The `dismissed` state is fully internal and persists for the lifetime of the mounted component. Because React reconciles components by position/type, if a parent reuses this component slot for a different next-step (e.g. `title` changes from \"下一步:分班\" to \"下一步:缴费\") without remounting, the hint will stay hidden even though the business state has changed and the hint should be shown again. Consider supporting a controlled `dismissed`/`onClose` pattern (or resetting dismissal when the `title` changes) so parents can re-show the hint after it becomes relevant again.","suggestion_code":null,"existing_code":"const [dismissed, setDismissed] = useState(false);\n if (dismissed) return null;"}
{"path":"apps/admin/src/components/MatchSelector.tsx","start_line":27,"end_line":27,"category":"style","severity":"low","content":"The identical static inline style object `{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }` is duplicated in all three action branches (match / create / default). Per the review rules, static (non-dynamic) inline styles should be avoided, and this is also duplicate code. Extract the shared layout into a module-level constant (e.g. `const rowStyle = {...}`) or a CSS class, and reuse it in the three branches.","suggestion_code":null,"existing_code":"<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>"}
{"path":"apps/admin/src/components/MatchSelector.tsx","start_line":93,"end_line":93,"category":"maintainability","severity":"low","content":"Passing `value={undefined}` explicitly to the Select is redundant: this branch is unmounted as soon as a selection is made (the parent switches `action` to 'match'), so there is nothing to reset, and the explicit prop makes the controlled/uncontrolled behavior ambiguous. Omit the `value` prop (or use `defaultValue`) so it is a clean uncontrolled input.","suggestion_code":null,"existing_code":" value={undefined}"}
{"path":"apps/admin/src/components/MatchStep.tsx","start_line":74,"end_line":78,"category":"bug","severity":"medium","content":"The connector SVG is absolutely positioned with `top: 0` inside the non-scrolling flex container, while the left/right row panels scroll internally (`overflowY: 'auto'`). After the user scrolls either panel, the row centers move up by `scrollTop` but the SVG lines stay fixed, so the match lines no longer align with the correct rows — this breaks the core visual of the component. Track the current `scrollTop` (e.g., in local state updated in the `onScroll` handlers for both panels) and apply `transform: translateY(-${scrollTop}px)` to the `<svg>` so the lines follow the rows.","suggestion_code":" <svg\n style={{\n position: 'absolute',\n top: 0,\n left: 0,\n transform: `translateY(-${scrollTop}px)`,","existing_code":" <svg\n style={{\n position: 'absolute',\n top: 0,\n left: 0,"}
{"path":"apps/admin/src/components/MatchStep.tsx","start_line":105,"end_line":105,"category":"maintainability","severity":"low","content":"Nested ternary expression — the review rules prohibit nested ternaries. Extract the row background into a small helper (or a lookup) for readability, e.g. `const getRowBackground = (isMatched, i) => isMatched ? '#f6ffed' : (i % 2 === 0 ? '#fafafa' : '#fff');`","suggestion_code":" background: getRowBackground(isMatched, i),","existing_code":" background: isMatched ? '#f6ffed' : i % 2 === 0 ? '#fafafa' : '#fff',"}
{"path":"apps/admin/src/components/MatchStep.tsx","start_line":73,"end_line":73,"category":"maintainability","severity":"low","content":"Extensive static inline `style` attributes throughout the component (row styles, flex container, etc.) violate the 'avoid inline styles except for dynamic styles' rule and make the markup hard to maintain. Consider extracting the static style objects into module-level constants or a CSS/antd `styles` property so only genuinely dynamic values (matched background, border color, etc.) remain inline.","suggestion_code":null,"existing_code":" <div style={{ display: 'flex', position: 'relative' }}>"}
{"path":"apps/admin/src/components/ImportWizard/ImportWizardModal.tsx","start_line":165,"end_line":168,"category":"bug","severity":"medium","content":"Stale-state bug: the Modal is never destroyed (`destroyOnHidden={false}`), so `run`, `receipt`, `previewByStep`, etc. persist across open/close. When the modal is reopened with `runId === null` (manual mode, per the prop contract “为空时向导从上传文件开始”) after a previous run was loaded, this effect returns early and the wizard renders the old run's data instead of the upload screen. Reset all wizard state when `open` becomes true without a runId (or clear it on close/onCancel).","suggestion_code":null,"existing_code":" useEffect(() => {\n if (!open || !initialRunId) return;\n void loadRun(initialRunId);\n }, [open, initialRunId, loadRun]);"}
{"path":"apps/admin/src/components/ImportWizard/ImportWizardModal.tsx","start_line":255,"end_line":257,"category":"bug","severity":"medium","content":"Misleading error handling: `commitImportStep` and the follow-up `getImportRun` refresh are in the same try/catch. If the commit succeeds but the refresh fails, the catch reports “提交失败” even though the data was actually committed, leaving the UI inconsistent (receipt set but run state not refreshed). Move the `getImportRun` refresh out of the commit's try block (or give it its own error handling) so a refresh failure doesn't mask a successful commit.","suggestion_code":null,"existing_code":" const result = await commitImportStep(run.id, activeStepKey, decisions);\n setReceipt(result);\n const refreshed = await getImportRun(run.id);"}
{"path":"apps/admin/src/components/ImportWizard/ImportWizardModal.tsx","start_line":383,"end_line":388,"category":"maintainability","severity":"low","content":"Nested ternary violates the project rule (“Nested ternary expressions are not allowed”). Extract the step status mapping into a small helper (e.g., `stepStatus(step, activeStepKey)`) to keep it linear and readable.","suggestion_code":null,"existing_code":" status:\n step.status === 'committed'\n ? ('finish' as const)\n : step.stepKey === activeStepKey\n ? ('process' as const)\n : ('wait' as const),"}
{"path":"apps/admin/src/components/ImportWizard/ImportWizardModal.tsx","start_line":411,"end_line":416,"category":"bug","severity":"low","content":"The hint text promises “单文件不超过 10MB”, but no actual size/type validation is performed in `customRequest` (the `accept` attribute is also not enforced on drag-and-drop). Oversized or wrong-format files are sent to the server and fail later with a generic error. Add a `beforeUpload` check (size ≤ 10MB and extension .xlsx/.csv) with a user-friendly rejection message.","suggestion_code":null,"existing_code":" <Upload.Dragger\n accept=\".xlsx,.csv\"\n maxCount={1}\n showUploadList={false}\n disabled={uploading}\n customRequest={handleUpload}"}
{"path":"apps/admin/src/components/PermissionButton.tsx","start_line":15,"end_line":16,"category":"bug","severity":"medium","content":"`hasPermission` returns `false` in two distinct situations: while the permission list is still loading (`permissionStatus !== 'ready'`) and when the permission is genuinely absent. Because the check happens directly in render, every button rendered by this component is hidden during the initial permission fetch and then pops in once loading completes, causing layout jumps; worse, if the permission request fails and `status` never becomes `'ready'`, all permission-gated buttons are permanently hidden with no user feedback. Consider exposing the loading state (e.g., `permissionsReady` from `usePermission`) and rendering a disabled placeholder or skeleton while loading, and/or optionally supporting a disabled fallback instead of always unmounting the button via `return null`.","suggestion_code":null,"existing_code":" const { hasPermission } = usePermission();\n if (!hasPermission(permission)) return null;"}
{"path":"apps/admin/src/components/JinshujuMatchModal.tsx","start_line":82,"end_line":86,"category":"bug","severity":"medium","content":"`res.success` is never checked here, unlike `handlePreview` below which throws on `!res.success`. If the API returns `success: false` (e.g., invalid credentials), the flow silently advances to the rule step with `response.data` possibly undefined — `response.data.fields` then throws a TypeError that is swallowed by the catch without any user-facing message. Add a success check before using `response.data`.","suggestion_code":" const response = await api.post<{\n success: boolean;\n data: { name: string; fields: JinshujuFormField[] };\n }>('/sync/jinshuju/fields', values);\n if (!response.success) throw new Error('获取表单字段失败');\n setFormFields(response.data.fields);","existing_code":" const response = await api.post<{\n success: boolean;\n data: { name: string; fields: JinshujuFormField[] };\n }>('/sync/jinshuju/fields', values);\n setFormFields(response.data.fields);"}
{"path":"apps/admin/src/components/JinshujuMatchModal.tsx","start_line":145,"end_line":149,"category":"bug","severity":"high","content":"When the apply API returns `success: false`, the `if (res.success)` block is skipped, nothing is thrown, and the catch never runs — so the modal stays permanently stuck on the 'applying' loading screen with no error message and no way to return to the match step (footer is null). This is inconsistent with `handlePreview` and `handleConnectionNext` error handling. Also, `res.log` may be undefined, so `res.log.message` can throw a TypeError that leaves the user without feedback. Handle the failure branch explicitly.","suggestion_code":" if (res.success) {\n message.success(res.log?.message || `处理 ${res.log?.recordsCount ?? 0} 条记录`);\n onApplied();\n reset();\n } else {\n throw new Error(res.log?.message || '同步失败');\n }","existing_code":" if (res.success) {\n message.success(res.log.message || `处理 ${res.log.recordsCount} 条记录`);\n onApplied();\n reset();\n }"}
{"path":"apps/admin/src/components/JinshujuMatchModal.tsx","start_line":341,"end_line":341,"category":"style","severity":"low","content":"Nested ternary expression violates the project rule that nested ternaries are not allowed. Extract the step-to-index mapping into a lookup or if/else for readability.","suggestion_code":" const stepIndexMap: Record<typeof step, number> = { connection: 0, rule: 1, match: 2, applying: 2 };\n const currentStep = stepIndexMap[step];","existing_code":" const currentStep = step === 'connection' ? 0 : step === 'rule' ? 1 : 2;"}
{"path":"apps/admin/src/components/JinshujuMatchModal.tsx","start_line":351,"end_line":353,"category":"style","severity":"low","content":"The footer rendering also uses nested ternaries (step === 'connection' ? ... : step === 'rule' ? ... : step === 'match' ? ... : null). Refactor into a lookup or if/else to comply with the no-nested-ternary rule and improve readability.","suggestion_code":null,"existing_code":" footer={\n step === 'connection'\n ? ["}
{"path":"apps/admin/src/components/JinshujuMatchModal.tsx","start_line":65,"end_line":68,"category":"maintainability","severity":"low","content":"The queryFn swallows all load failures (`catch { return []; }`) with no user feedback. A failed rules fetch is indistinguishable from \"no rules exist\", so the UI may misleadingly show '当前表单还没有保存的规则' when the request actually failed. Consider surfacing a lightweight error message (or at least distinguishing the failure state) instead of silently returning an empty list.","suggestion_code":null,"existing_code":" return res.success ? validateResponse<MatchRule[]>(jinshujuRulesSchema, res.data) : [];\n } catch {\n return [];\n }"}
{"path":"apps/admin/src/components/QueryState/QueryErrorState.tsx","start_line":27,"end_line":27,"category":"maintainability","severity":"low","content":"Static inline styles are used in the compact branch (`padding: '32px 16px', textAlign: 'center'`, `fontSize: 12`, `marginTop: 12`). Per the project's review rules, inline `style` attributes should be avoided except for truly dynamic styles — consider extracting these into CSS classes or theme tokens for consistency and maintainability.","suggestion_code":null,"existing_code":"<div style={{ padding: '32px 16px', textAlign: 'center' }}>"}
{"path":"apps/admin/src/components/NotificationBell.tsx","start_line":37,"end_line":40,"category":"bug","severity":"medium","content":"Copy-paste bug: this is the error handler for `fetchNotifications` (fetching the notification list), but both the console log and the user-facing message say \"全部已读失败\" (mark-all-read failed). This misleads users/debugging when the fetch itself fails. Should be \"获取通知失败,请重试\".","suggestion_code":" } catch (error) {\n console.error('获取通知失败', error);\n message.error('获取通知失败,请重试');\n }","existing_code":" } catch (error) {\n console.error('全部已读失败', error);\n message.error('全部已读失败,请重试');\n }"}
{"path":"apps/admin/src/components/NotificationBell.tsx","start_line":62,"end_line":62,"category":"security","severity":"medium","content":"The auth token is passed in the SSE URL query string (`?token=...`). Query-string parameters are commonly recorded in server/proxy access logs and browser history/devtools, increasing the risk of token leakage. Since REST calls already authenticate via the Authorization header (the api interceptor adds it), consider using a short-lived, single-use stream token, or switch to a mechanism that doesn't expose the token in the URL.","suggestion_code":null,"existing_code":" const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`);"}
{"path":"apps/admin/src/components/NotificationBell.tsx","start_line":101,"end_line":103,"category":"bug","severity":"medium","content":"`handleMarkAll` swallows the failure silently (`/* ignore */`). Marking all notifications read is a user-triggered destructive action; if the request fails the UI stays optimistic-looking (list already updated locally) with no feedback, and the unread count will be corrected by the next poll/SSE only. Add a user-friendly error message in the catch block (e.g., `message.error('全部已读失败,请重试')`) to be consistent with the rest of the component.","suggestion_code":" const handleMarkAll = async () => {\n try {\n await api.put('/notifications/read-all');\n setUnreadCount(0);\n setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));\n } catch {\n message.error('全部已读失败,请重试');\n }\n };","existing_code":" const handleMarkAll = async () => {\n try {\n await api.put('/notifications/read-all');"}
{"path":"apps/admin/src/components/NotificationBell.tsx","start_line":51,"end_line":52,"category":"maintainability","severity":"low","content":"`openRef.current = open;` writes to a ref during render, which violates React's rules (\"Do not write or read ref.current during rendering\"). With concurrent rendering/StrictMode the ref can be left stale if the render is discarded, so the SSE `onmessage` handler may make the wrong decision about refreshing the list. Prefer updating the ref in an effect.","suggestion_code":" const openRef = useRef(open);\n useEffect(() => {\n openRef.current = open;\n }, [open]);","existing_code":" const openRef = useRef(open);\n openRef.current = open;"}
{"path":"apps/admin/src/components/PermissionRoute.tsx","start_line":21,"end_line":21,"category":"performance","severity":"low","content":"findRoleAwareLandingPath internally calls buildMenu/collectMenuPaths, which constructs the whole filtered menu tree. It is recomputed on every render of the unauthorized branch. Since this is a derived value from roles/permissions, memoize it with useMemo to avoid repeated menu construction on re-renders.","suggestion_code":" const firstPath = useMemo(\n () => findRoleAwareLandingPath(roles, permissions),\n [roles, permissions],\n );","existing_code":" const firstPath = findRoleAwareLandingPath(roles, permissions);"}
{"path":"apps/admin/src/components/PermissionRoute.tsx","start_line":28,"end_line":32,"category":"bug","severity":"low","content":"When the user has no permissions at all, findRoleAwareLandingPath returns null, so the 403 Result renders without any button - the user is left stranded on the 'no access' page with no way to navigate away or back to login. Consider always providing a fallback action (e.g., back to home or logout) even when firstPath is null.","suggestion_code":null,"existing_code":" firstPath ? (\n <Button type=\"primary\" onClick={() => navigate(firstPath, { replace: true })}>\n 前往可访问页面\n </Button>\n ) : undefined"}
{"path":"apps/admin/src/components/PermissionRoute.tsx","start_line":18,"end_line":18,"category":"style","severity":"low","content":"Static inline styles should be avoided per the project rules (inline style is only allowed for dynamic styles). Move the loading spinner centering to a CSS class instead of hardcoding it inline.","suggestion_code":null,"existing_code":" return <Spin size=\"large\" style={{ display: 'block', margin: '80px auto' }} />;"}
{"path":"apps/admin/src/components/ScrollToTop.tsx","start_line":10,"end_line":12,"category":"performance","severity":"low","content":"The scroll reset runs in `useEffect`, which executes after the browser has painted the new route. On navigation the user can briefly see the new page at the old scroll position (flicker), and on initial mount it also runs an unnecessary `window.scrollTo`. Since this effect only touches layout state synchronously, prefer `useLayoutEffect` so the scroll happens before paint (the standard pattern for ScrollToTop components). Alternatively/additionally, setting `history.scrollRestoration = 'manual'` once avoids conflicts with browser scroll restoration on back/forward navigation.","suggestion_code":" useLayoutEffect(() => {\n window.scrollTo(0, 0);\n }, [pathname]);","existing_code":" useEffect(() => {\n window.scrollTo(0, 0);\n }, [pathname]);"}
{"path":"apps/admin/src/components/RuleEditor.tsx","start_line":30,"end_line":32,"category":"bug","severity":"medium","content":"Hardcoded fallback mappings use arbitrary Jinshuju field keys (`field_1`, `field_2`) that are unlikely to exist in a real form's `fields` list. When creating a new rule, the Select will display the raw key value (no matching option) and an invalid mapping can be saved. Better to default to an empty object (or derive defaults from the first available form fields).","suggestion_code":" const [mappings, setMappings] = useState<Record<string, string>>(\n rule?.mappings ?? {},\n );","existing_code":" const [mappings, setMappings] = useState<Record<string, string>>(\n rule?.mappings ?? { name: 'field_1', phone: 'field_2' },\n );"}
{"path":"apps/admin/src/components/RuleEditor.tsx","start_line":33,"end_line":33,"category":"maintainability","severity":"low","content":"`saving` local state duplicates the pending state already exposed by `useApiMutation` (a wrapper around `useMutation`, which provides `isPending`). The extra state also forces a try/catch/finally dance that the mutation's built-in error handling already covers. Simplify by using `saveMutation.isPending` as the Button loading prop.","suggestion_code":null,"existing_code":" const [saving, setSaving] = useState(false);"}
{"path":"apps/admin/src/components/RuleEditor.tsx","start_line":65,"end_line":65,"category":"style","severity":"low","content":"Many static inline `style` objects are used throughout this component. Per project conventions (avoid inline styles except for dynamic ones), these should be extracted into CSS classes or a stylesheet for better maintainability.","suggestion_code":null,"existing_code":" <div style={{ padding: '12px 0' }}>"}
{"path":"apps/admin/src/components/StudentProfileContent/EditableArchiveCell.tsx","start_line":27,"end_line":27,"category":"maintainability","severity":"medium","content":"The `options` prop re-declares its own structural type and narrows it compared to `EditableCellOption` exported by `EditableCell` (`label: React.ReactNode` and `value: string | number | boolean`). This silently restricts valid use cases (e.g., boolean-valued select options or labels rendered as ReactNode) and lets the type drift from the source of truth. Reuse the underlying prop type instead, e.g. `options?: React.ComponentProps<typeof EditableCell>['options']`.","suggestion_code":" options?: React.ComponentProps<typeof EditableCell>['options'];","existing_code":" options?: Array<{ value: string | number; label: string }>;"}
{"path":"apps/admin/src/components/StudentProfileContent/EditableArchiveCell.tsx","start_line":39,"end_line":41,"category":"maintainability","severity":"low","content":"The `async`/`await` wrapper around `onSave` adds no behavior beyond the raw callback (both propagate rejections to `EditableCell`'s try/catch). Simplify to a direct delegation for readability.","suggestion_code":" onSave={(next) => onSave(record, field, next)}","existing_code":" onSave={async (next) => {\n await onSave(record, field, next);\n }}"}
{"path":"apps/admin/src/components/StudentProfileContent/EditableArchiveCell.tsx","start_line":43,"end_line":43,"category":"bug","severity":"low","content":"The default display fallback `String(value ?? '-')` renders non-primitive values poorly: arrays (e.g., for `multi-select`/`tags`/`date-range` editors) are joined as \"a,b,c\" and objects render as \"[object Object]\" in the UI. Since `value` is typed as `unknown`, consider a safer fallback that formats arrays/objects, or require callers to pass `children` for such editors.","suggestion_code":null,"existing_code":" {children ?? String(value ?? '-')}"}
{"path":"apps/admin/src/components/EditableCell/index.tsx","start_line":265,"end_line":269,"category":"bug","severity":"high","content":"Pressing Enter while using editors whose value commits through their own internal Enter handling (`date`/`date-range`/`select`/`multi-select`/`tags`) will also hit this keydown handler and call `save()` with the `draft` captured in the current render. Those controls only update `draft` via their own async `onChange` (e.g., rc-picker commits a typed date, rc-select selects a highlighted option, tags mode adds a tag), so `draft` here is still the pre-interaction stale value. Depending on event ordering, `event.preventDefault()` may also suppress the control's commit — the user's typed date / selected option / typed tag is either silently discarded or the previous value gets saved over the new one (and two concurrent `onSave` calls can race). Restrict the Enter shortcut to editors whose draft is already in sync with the input (text/textarea/number/money).","suggestion_code":" if (\n event.key === 'Enter' &&\n (editor === 'text' || editor === 'textarea' || editor === 'number' || editor === 'money')\n ) {\n event.preventDefault();\n await save();\n return;\n }","existing_code":" if (event.key === 'Enter' && editor !== 'textarea') {\n event.preventDefault();\n await save();\n return;\n }"}
{"path":"apps/admin/src/components/EditableCell/index.tsx","start_line":95,"end_line":95,"category":"bug","severity":"medium","content":"`crypto.randomUUID()` is only available in secure contexts (https/localhost) and relatively modern browsers (e.g., Safari < 15.4 throws). This is invoked during render inside `useRef`, so on an intranet http deployment or older engine the whole component crashes with a TypeError. Add a fallback ID generator.","suggestion_code":" const idRef = useRef(\n (() => {\n if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {\n return crypto.randomUUID();\n }\n return `editable-cell-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;\n })(),\n );","existing_code":" const idRef = useRef(crypto.randomUUID());"}
{"path":"apps/admin/src/components/EditableCell/index.tsx","start_line":119,"end_line":120,"category":"maintainability","severity":"low","content":"`normalizeEditableValue(formatValue ? formatValue(value) : value, editor)` is duplicated four times (initial `draft` state, `original` memo, `cancel`, `beginEdit`). If the display-value normalization ever changes for one editor type, the copies can diverge and cause the unchanged-value equality check (and thus unnecessary saves) to behave inconsistently. Extract a single helper and reuse it in all four places.","suggestion_code":"function normalizeDisplayValue<Value>(\n value: Value,\n editor: EditableCellEditor,\n formatValue?: (value: Value) => unknown,\n) {\n return normalizeEditableValue(formatValue ? formatValue(value) : value, editor);\n}","existing_code":" const cancel = useCallback(() => {\n setDraft(normalizeEditableValue(formatValue ? formatValue(value) : value, editor));"}
{"path":"apps/admin/src/components/EditableCell/index.tsx","start_line":273,"end_line":275,"category":"maintainability","severity":"low","content":"`value: draft as never` casts away all editor-specific types. Besides losing type safety, it has a runtime edge case: with editor `'text'` and a numeric `value`, `original` stays a number while any user input becomes a string, so `editableValuesEqual(serialized, original)` (fast-deep-equal) always returns false and an unmodified cell triggers a redundant `onSave`. Consider modeling `draft` as a discriminated union per editor instead of `as never`.","suggestion_code":null,"existing_code":" const commonProps = {\n autoFocus: true,\n value: draft as never,"}
{"path":"apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx","start_line":83,"end_line":85,"category":"security","severity":"high","content":"Opening user-uploaded attachment bytes via `URL.createObjectURL` + `window.open(url, '_blank')` is a stored-XSS vector: a blob URL inherits the creator page's origin, so if the attachment is an HTML/SVG file containing scripts, those scripts run in the admin's origin/session. Prefer forcing a download instead — e.g. build an `<a>` with a `download` attribute and click it, set the blob type to `application/octet-stream`, or serve the file through a dedicated endpoint returning `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`. Also note `window.open` may return null when blocked by popup blockers, and revoking the URL after a fixed 60s can break a preview tab that stays open longer.","suggestion_code":null,"existing_code":" const url = URL.createObjectURL(blob);\n window.open(url, '_blank');\n setTimeout(() => URL.revokeObjectURL(url), 60_000);"}
{"path":"apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx","start_line":80,"end_line":82,"category":"bug","severity":"low","content":"The shared axios instance (`api/index.ts`) has a hardcoded 10s timeout, which also applies to this attachment download. Larger files may abort mid-download. Pass a larger per-request timeout (e.g. `timeout: 60000`) or stream the download instead.","suggestion_code":null,"existing_code":" const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {\n responseType: 'blob',\n });"}
{"path":"apps/admin/src/components/StudentProfileContent/AttachmentsTab.tsx","start_line":23,"end_line":23,"category":"maintainability","severity":"low","content":"Endpoint URL paths are hardcoded and duplicated inline (`/archive/attachments/${attachmentId}`, `/archive/attachments/${id}/permanent`, `/archive/${studentId}/attachments`). Per the project's hardcoding rule, centralize these paths in the api layer (e.g. an `api/archive.ts`) so endpoints have a single source of truth.","suggestion_code":null,"existing_code":" async (attachmentId: number) => api.delete(`/archive/attachments/${attachmentId}`),"}
{"path":"apps/admin/src/components/RouteDock/index.tsx","start_line":126,"end_line":129,"category":"maintainability","severity":"medium","content":"Hardcoded business URL '/dashboard' violates the no-hardcoded-URL-path rule and creates an inconsistency: if the current active tab is already '/dashboard', `onNavigate` is a no-op, so the useEffect never re-runs and the dock stays permanently empty (while from any other route the dashboard tab is re-created). Derive the fallback destination from the menu items / route config (e.g. the first menu key) and guard with `if (location.pathname !== target) onNavigate(target)`, or explicitly re-insert the home tab.","suggestion_code":null,"existing_code":" const closeAll = () => {\n setRouteDockTabs([]);\n onNavigate('/dashboard');\n };"}
{"path":"apps/admin/src/components/RouteDock/index.tsx","start_line":49,"end_line":50,"category":"maintainability","severity":"medium","content":"Business route patterns (/students/:id/profile, /classes/:id) are hardcoded in this component purely for label lookup. This duplicates routing knowledge and will silently produce wrong labels if routes are renamed. Recommend centralizing the path → label mapping in a route config or menu metadata instead of maintaining regex fallbacks here.","suggestion_code":null,"existing_code":" if (/^\\/students\\/\\d+\\/profile$/.test(pathname)) return '学生档案';\n if (/^\\/classes\\/\\d+$/.test(pathname)) return '班级详情';"}
{"path":"apps/admin/src/components/RouteDock/index.tsx","start_line":87,"end_line":87,"category":"maintainability","severity":"low","content":"`activeKey` is just `location.pathname` (line 77), so the dependency array lists the same value twice. Keeping both is confusing and risks drift if the derivation changes; keep only `location.pathname` (and note that `menuItems` can be a large rebuilt array — a stable reference from the parent is relied upon here).","suggestion_code":null,"existing_code":" }, [activeKey, location.pathname, menuItems, setRouteDockTabs]);"}
{"path":"apps/admin/src/components/RouteKeeper.tsx","start_line":24,"end_line":26,"category":"bug","severity":"medium","content":"Mutating refs (cacheRef/orderRef) directly during render is a render-time side effect and breaks React render purity. With React 18 StrictMode and concurrent rendering, the render function can run multiple times before commit and ref mutations are not rolled back. The `has()` guard makes it idempotent in the common case, but this pattern is fragile (e.g., an aborted render still leaves the entry cached). Move the cache bookkeeping into `useLayoutEffect` (backed by state that triggers re-render) or use the documented \"adjust state during render\" pattern so the render phase stays pure.","suggestion_code":null,"existing_code":" if (outlet && !cacheRef.current.has(pageKey)) {\n cacheRef.current.set(pageKey, outlet);\n orderRef.current.push(pageKey);"}
{"path":"apps/admin/src/components/RouteKeeper.tsx","start_line":27,"end_line":30,"category":"bug","severity":"medium","content":"The LRU eviction logic has two problems: (1) re-visiting an already cached page never refreshes its recency, so a page that was just viewed can still be treated as the oldest; (2) when the eviction target happens to be the currently active page (pageKey was inserted long ago and never re-touched), `oldest !== pageKey` skips the deletion but `shift()` has already removed the key from orderRef — cacheRef and orderRef become desynced and that page is never evicted/tracked again (cache grows permanently by one entry per occurrence). Fix by refreshing recency on access, which also guarantees the active page can never be the oldest so the deletion can be unconditional.","suggestion_code":" if (cacheRef.current.has(pageKey)) {\n // 重新访问时刷新 LRU 顺序,避免活跃页被误判为最旧\n const idx = orderRef.current.indexOf(pageKey);\n if (idx > -1) {\n orderRef.current.splice(idx, 1);\n orderRef.current.push(pageKey);\n }\n } else if (outlet) {\n cacheRef.current.set(pageKey, outlet);\n orderRef.current.push(pageKey);\n if (orderRef.current.length > MAX_CACHED_PAGES) {\n const oldest = orderRef.current.shift();\n if (oldest) cacheRef.current.delete(oldest);\n }\n }","existing_code":" if (orderRef.current.length > MAX_CACHED_PAGES) {\n const oldest = orderRef.current.shift();\n if (oldest && oldest !== pageKey) cacheRef.current.delete(oldest);\n }"}
{"path":"apps/admin/src/components/RouteKeeper.tsx","start_line":35,"end_line":40,"category":"bug","severity":"medium","content":"Hidden cached pages are not truly frozen: they stay mounted as children of RouteKeeper and re-render on every navigation (useLocation triggers a RouteKeeper re-render, and the map re-renders all cached subtrees). Because they read the router-level LocationContext, `useLocation()`/`useSearchParams()` inside a hidden page return the *current* route's location rather than the location it was cached with, so effects that depend on location may fire while the page is hidden and up to MAX_CACHED_PAGES subtrees re-render on each navigation. Consider memoizing each cached page boundary and/or providing a location context captured at cache time so hidden pages don't react to unrelated navigations.","suggestion_code":null,"existing_code":" {Array.from(cacheRef.current.entries()).map(([key, node]) => (\n <div\n key={key}\n className=\"route-keeper-page\"\n style={{ display: key === pageKey ? undefined : 'none' }}\n >"}
{"path":"apps/admin/src/components/RouteKeeper.tsx","start_line":20,"end_line":22,"category":"maintainability","severity":"low","content":"Keying only by pathname means URLs sharing a pathname but differing in query params (e.g. search/filter pages `/list?keyword=a` and `/list?keyword=b`) share a single frozen cache entry: the cached node keeps the props/element captured on the first visit, so loader data or param-derived props never update when the same pathname is visited with different params — only direct router-context reads (useSearchParams) see the new URL. This is a deliberate design (documented in the comment), but pages relying on param/loader-derived content can show stale data; either include the relevant search params in the cache key or ensure such pages read state from the router context.","suggestion_code":null,"existing_code":" // 仅以 pathname 作为缓存键:页面内部通过 URL 参数同步状态时不会\n // 产生第二个实例,切回时也不会因此重挂载。\n const pageKey = location.pathname;"}
{"path":"apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx","start_line":42,"end_line":42,"category":"bug","severity":"medium","content":"Clearing an editable field via inline cell editing does not actually persist. `EditableCell.saveValue` serializes a cleared value to `undefined` for date/number/select editors (see `serializeEditableValue` in EditableCell/index.tsx), and this `saveCell` builds the payload `{ [field]: value }`. `JSON.stringify` silently drops `undefined` properties, so the request body contains no `examDate`/`classAvg`/`rank` key and the server cannot clear the field — the value reappears after refetch. Send `null` (or explicitly handle `undefined`) so the backend can clear nullable fields.","suggestion_code":" api.put(`/archive/exam-scores/${id}`, { [field]: value ?? null }),","existing_code":" api.put(`/archive/exam-scores/${id}`, { [field]: value }),"}
{"path":"apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx","start_line":137,"end_line":137,"category":"bug","severity":"low","content":"Display inconsistency with `null` values: the `score` cell uses `v ?? '-'` (which renders '-' for `null`), but `classAvg` and `rank` use `v !== undefined ? v : '-'`, which renders a blank cell when the API returns `null` (React renders `null` as nothing). Use `v ?? '-'` here too for a consistent fallback display.","suggestion_code":" {v ?? '-'}","existing_code":" {v !== undefined ? v : '-'}"}
{"path":"apps/admin/src/components/StudentProfileContent/ExamScoresTab.tsx","start_line":37,"end_line":37,"category":"maintainability","severity":"low","content":"API endpoint paths (`/archive/${studentId}/exam-scores`, `/archive/exam-scores/${id}`, `/archive/exam-scores/${id}/permanent`) are hardcoded business strings, which the review rules prohibit. Note this pattern is repeated across the sibling tabs (EnrollmentsTab/LearningTab/AttachmentsTab) and the parent page; consider centralizing these paths in a shared archive API module/constants so path changes are applied consistently.","suggestion_code":null,"existing_code":" api.post(`/archive/${studentId}/exam-scores`, payload),"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":515,"end_line":519,"category":"security","severity":"medium","content":"Using document.write() to inject the report HTML is explicitly prohibited by the security checklist (page reflow + XSS surface). The HTML returned by `/archive/:id/report-html` embeds student data; if the server-side template does not escape fields (name, notes, etc.), arbitrary script can execute in the new window context. Also, if `window.open` returns null (popup blocked), the user gets no feedback. Prefer opening a Blob URL: `const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const w = window.open(url, '_blank');` (and revoke the URL later / notify when the popup is blocked).","suggestion_code":" const blob = new Blob([html], { type: 'text/html' });\n const url = URL.createObjectURL(blob);\n const w = window.open(url, '_blank');\n if (!w) {\n message.error('预览报告被浏览器拦截,请允许弹出窗口');\n }","existing_code":" const w = window.open('', '_blank');\n if (w) {\n w.document.write(html);\n w.document.close();\n }"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":290,"end_line":308,"category":"maintainability","severity":"low","content":"This is a nested ternary (`canChooseOrganization ? ... : student.organization?.name ? ... : ...`), which is prohibited by the review rules and is hard to read/maintain. Flatten it by pre-computing the organization tag before the return statement, e.g. `const orgTag = student.organization?.name ? <Tag color=\"purple\">{student.organization.name}</Tag> : '-';`, then use a single conditional.","suggestion_code":" {canChooseOrganization ? (\n <EditableCell\n value={student.organizationId}\n editor=\"select\"\n options={organizations.map((item) => ({ value: item.id, label: item.name }))}\n permission=\"student:edit\"\n onSave={(next) => saveStudent('organizationId', next)}\n >\n {orgTag}\n </EditableCell>\n ) : (\n orgTag\n )}","existing_code":" {canChooseOrganization ? (\n <EditableCell\n value={student.organizationId}\n editor=\"select\"\n options={organizations.map((item) => ({ value: item.id, label: item.name }))}\n permission=\"student:edit\"\n onSave={(next) => saveStudent('organizationId', next)}\n >\n {student.organization?.name ? (\n <Tag color=\"purple\">{student.organization.name}</Tag>\n ) : (\n '-'\n )}\n </EditableCell>\n ) : student.organization?.name ? (\n <Tag color=\"purple\">{student.organization.name}</Tag>\n ) : (\n '-'\n )}"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":125,"end_line":126,"category":"maintainability","severity":"low","content":"`onRefresh` is declared in the props type and passed by the parent (`onRefresh={fetchData}`), but it is never destructured or referenced anywhere inside `InlineArchiveSummary` — dead prop. Remove it from the type, the destructuring-free usage, and the call site (or wire it up if refreshing was intended after edits).","suggestion_code":null,"existing_code":" onRefresh: () => void;\n onViewSensitive: (fieldLabel: string, value: string) => void;"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":620,"end_line":631,"category":"maintainability","severity":"low","content":"All four statistics cards (入学测试总分 / 阶段最高分 / 阶段提升分 / 出勤率) are hardcoded to display '-' and are never derived from `aggregateData` (e.g. examScores/attendances). This is an incomplete feature rendered as permanent placeholders. Either compute the real values from the aggregate data or remove the row until implemented.","suggestion_code":null,"existing_code":" {[\n { title: '入学测试总分' },\n { title: '阶段最高分' },\n { title: '阶段提升分' },\n { title: '出勤率' },\n ].map((item) => (\n <Col span={6} key={item.title}>\n <Card size=\"small\">\n <Statistic title={item.title} value=\"-\" />\n </Card>\n </Col>\n ))}"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":566,"end_line":570,"category":"maintainability","severity":"low","content":"The '报告版本' tab always renders `<Empty description=\"暂无报告版本\" />` regardless of any data — a hardcoded placeholder for an unimplemented feature. If report versions exist on the server, this is misleading; otherwise consider removing the tab until it is implemented.","suggestion_code":null,"existing_code":" {\n key: 'reports',\n label: '报告版本',\n children: <Empty description=\"暂无报告版本\" />,\n },"}
{"path":"apps/admin/src/components/StudentProfileContent/index.tsx","start_line":598,"end_line":598,"category":"maintainability","severity":"low","content":"The `<style>` tag injects a global `.archived-row` CSS rule on every render of this component (including when it is only used to render child tables). If the component mounts multiple times (e.g. inside a drawer and on a page), duplicate style tags are inserted and the global class name can collide with unrelated styles. Prefer defining this rule once in a global stylesheet/CSS module instead of inline per-render injection.","suggestion_code":null,"existing_code":" <style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>"}
{"path":"apps/admin/src/pages/AiConfig/AiConfigSteps.tsx","start_line":290,"end_line":292,"category":"maintainability","severity":"medium","content":"Nested ternary is prohibited by the review rules. The tag rendering here is a ternary nested inside another ternary, and the `Alert` `type` prop just below has the same pattern (`success ? (modelAvailable ? 'success' : 'warning') : 'error'`). Extract the logic into a helper/const (e.g. compute `status` and `type` before the return) to keep it flat and readable.","suggestion_code":" {(() => {\n if (testResult.success && testResult.modelAvailable) {\n return <Tag icon={<CheckCircleOutlined />} color=\"success\">成功</Tag>;\n }\n if (testResult.success) {\n return <Tag icon={<WarningOutlined />} color=\"warning\">模型未找到</Tag>;\n }\n return <Tag icon={<CloseCircleOutlined />} color=\"error\">失败</Tag>;\n })()}","existing_code":" {testResult.success ? (\n testResult.modelAvailable ? (\n <Tag icon={<CheckCircleOutlined />} color=\"success\">"}
{"path":"apps/admin/src/pages/AiConfig/AiConfigSteps.tsx","start_line":371,"end_line":373,"category":"maintainability","severity":"medium","content":"Nested ternary is prohibited by the review rules. The key-preview rendering nests a ternary inside another ternary. Prefer extracting the tag into a variable (e.g. `const keyTag = config?.hasApiKey ? ... : hasFormKey ? ... : ...;`) before the return, or use an if-chain.","suggestion_code":null,"existing_code":" {config?.hasApiKey ? (\n <Tag color=\"green\">{config.maskedApiKey || '••••'}</Tag>\n ) : hasFormKey ? ("}
{"path":"apps/admin/src/pages/AiConfig/AiConfigSteps.tsx","start_line":353,"end_line":353,"category":"maintainability","severity":"low","content":"`formValues.apiKey && formValues.apiKey !== '••••'` evaluates to a string (or `''`), not a boolean, so `hasFormKey` has a string type despite being used as a condition and semantically being a boolean flag. Coerce with `!!` for a proper boolean type.","suggestion_code":" const hasFormKey = !!formValues.apiKey && formValues.apiKey !== '••••';","existing_code":" const hasFormKey = formValues.apiKey && formValues.apiKey !== '••••';"}
{"path":"apps/admin/src/pages/AiConfig/AiConfigSteps.tsx","start_line":112,"end_line":112,"category":"maintainability","severity":"low","content":"The condition `!canWrite || (isFixedProvider && canWrite)` is redundant: whenever `canWrite` is false the first term already disables the input, and when `canWrite` is true the second term reduces to `isFixedProvider`. It can be simplified to `!canWrite || isFixedProvider`, which makes the intent (baseUrl is locked whenever the provider is fixed) clearer.","suggestion_code":" disabled={!canWrite || isFixedProvider}","existing_code":" disabled={!canWrite || (isFixedProvider && canWrite)}"}
{"path":"apps/admin/src/pages/AiConfig/AiConfigSteps.tsx","start_line":156,"end_line":156,"category":"maintainability","severity":"low","content":"Many static inline styles are used throughout this file (e.g. `style={{ marginBottom: 12 }}`, `style={{ marginLeft: 8, fontSize: 12, color: '#999' }}`, `style={{ marginBottom: 8 }}`, `style={{ marginTop: 8 }}`, `style={{ marginBottom: 16 }}`). Per the review rules, inline styles should be avoided except for dynamic values; these repeated static styles should be moved into the CSS module (index.module.css).","suggestion_code":null,"existing_code":" <Descriptions column={1} size=\"small\" style={{ marginBottom: 12 }}>"}
{"path":"apps/admin/src/layouts/MainLayout.tsx","start_line":258,"end_line":258,"category":"maintainability","severity":"medium","content":"The return type is `any[]`, which disables type checking for the menu items passed to antd's `Menu` (a typo in a key/label would silently pass through). Use `MenuProps['items']` from antd instead (and type the item via `NonNullable<MenuProps['items']>[number]`), or at least a properly typed local interface, so the map result stays type-safe.","suggestion_code":" const transformToMenuItems = useCallback((items: AppMenuItem[]): MenuProps['items'] => {","existing_code":" const transformToMenuItems = useCallback((items: AppMenuItem[]): any[] => {"}
{"path":"apps/admin/src/layouts/MainLayout.tsx","start_line":114,"end_line":114,"category":"maintainability","severity":"low","content":"The profile endpoint path `/auth/profile` is hardcoded inline in the layout. Per the review rules, business URL paths should not be hardcoded at call sites — centralize it in the api layer (e.g., a `api.auth.getProfile()` wrapper or an endpoint constant) so path changes and API shaping stay in one place.","suggestion_code":null,"existing_code":" '/auth/profile',"}
{"path":"apps/admin/src/layouts/MainLayout.tsx","start_line":122,"end_line":126,"category":"performance","severity":"medium","content":"On any persistent profile-fetch failure (e.g., expired/invalid token returning 401, or a server outage), this catch block retries `verifyPermissions` every 5 seconds forever. The tab keeps hitting `/auth/profile` indefinitely in the background (also re-triggered on every `visibilitychange`/`online`), and the user gets no error feedback. Stop retrying on authentication errors (401/403), cap the number of retries, and surface a user-visible error (or redirect to login) when the profile can no longer be verified.","suggestion_code":null,"existing_code":" .catch(() => {\n verificationInFlight = false;\n if (cancelled || !useUserStore.getState().token) return;\n retryTimer = window.setTimeout(verifyPermissions, 5_000);\n });"}
{"path":"apps/admin/src/layouts/MainLayout.tsx","start_line":325,"end_line":325,"category":"bug","severity":"low","content":"antd's `Drawer` `size` prop only accepts the preset values (`'default'` / `'large'`); a numeric `240` is not a valid `size` and won't apply the intended 240px width (the drawer falls back to the default width). If the goal is a 240px-wide drawer, use the `width` prop (`width={240}`) instead of `size`.","suggestion_code":" width={240}","existing_code":" size={240}"}
{"path":"apps/admin/src/layouts/MainLayout.tsx","start_line":339,"end_line":341,"category":"maintainability","severity":"low","content":"This layout relies heavily on static inline `style` props (Header, Content, Sider, logo block, etc.). Per the review rules, inline styles should be reserved for dynamic values; static layout styling should live in CSS classes (e.g., `.app-header`, `.app-content`) so the JSX stays readable and the styles are consistent/reusable.","suggestion_code":null,"existing_code":" <Header\n className=\"app-header\"\n style={{"}
{"path":"apps/admin/src/components/StudentProfileContent/LearningTab.tsx","start_line":98,"end_line":100,"category":"bug","severity":"medium","content":"recordDate / recordType / content are marked `required` in the add-modal validation, but these inline editors do not pass the `required` prop to EditableArchiveCell. As a result a user can clear e.g. `content` inline (the text editor trims it to `''` and the empty string is persisted via PUT), and clearing the date picker produces `{ recordDate: undefined }`, which JSON-serializes to an empty body while the cell still reports success. Pass `required` to these three editable cells (matching ExamScoresTab's `subject` cell and the modal rules) to prevent required fields from being emptied inline.","suggestion_code":null,"existing_code":" <EditableArchiveCell value={v} field={LEARNING_FIELDS.recordDate} record={r} editor=\"date\" onSave={saveCell}>\n {v}\n </EditableArchiveCell>"}
{"path":"apps/admin/src/components/StudentProfileContent/LearningTab.tsx","start_line":108,"end_line":108,"category":"maintainability","severity":"low","content":"This manual label lookup duplicates the `getOptionLabel` helper already exported from `./shared`. Use `getOptionLabel(RECORD_TYPE_OPTIONS, v)` to keep the label resolution logic in one place and consistent with the rest of the module.","suggestion_code":null,"existing_code":" {RECORD_TYPE_OPTIONS.find((o) => o.value === v)?.label || v}"}
{"path":"apps/admin/src/components/StudentProfileContent/LearningTab.tsx","start_line":35,"end_line":35,"category":"maintainability","severity":"low","content":"API endpoints are hardcoded inline as template strings here and in the PUT/DELETE mutations below. Per the review rules, business URL paths should not be hardcoded — consider extracting archive endpoints into a shared constants module (or a small API wrapper) so paths stay consistent and are easy to test/maintain.","suggestion_code":null,"existing_code":" api.post(`/archive/${studentId}/learning-records`, payload),"}
{"path":"apps/admin/src/components/StudentProfileContent/LearningTab.tsx","start_line":161,"end_line":161,"category":"style","severity":"low","content":"Static inline style; per the review rules inline styles should be avoided except for dynamic styles. Move `marginBottom: 16` to a CSS class (same applies to `style={{ width: '100%' }}` on the DatePicker).","suggestion_code":null,"existing_code":" style={{ marginBottom: 16 }}"}
{"path":"apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx","start_line":52,"end_line":54,"category":"bug","severity":"medium","content":"`setSaving(true)` is only reached after `await form.validateFields()`. If the OK button is clicked twice in quick succession, both invocations can pass before React commits the `confirmLoading` state, causing two concurrent POST requests and duplicate enrollment records. Move the `saving` guard/set to the very start of the handler (the `finally` block already resets it).","suggestion_code":" if (saving) return;\n try {\n setSaving(true);\n const values = await form.validateFields();","existing_code":" try {\n const values = await form.validateFields();\n setSaving(true);"}
{"path":"apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx","start_line":250,"end_line":252,"category":"bug","severity":"medium","content":"No cross-field validation between 开始日期 and 结束日期: a record can be created with `endDate` earlier than `startDate`, or with only one of the two dates filled in (the server DTO marks both optional and performs no range check either). This can corrupt the enrollment timeline displayed elsewhere. Add a validator on `endDate` (using `dependencies` on `startDate`) to reject `endDate < startDate`, or enforce both-or-neither.","suggestion_code":null,"existing_code":" <Form.Item name=\"startDate\" label=\"开始日期\">\n <DatePicker style={{ width: '100%' }} />\n </Form.Item>"}
{"path":"apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx","start_line":204,"end_line":204,"category":"style","severity":"low","content":"Static inline style — per project conventions (inline styles only for dynamic values), this margin should be a CSS class/utility instead.","suggestion_code":null,"existing_code":" style={{ marginBottom: 16 }}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx","start_line":14,"end_line":17,"category":"bug","severity":"medium","content":"'late' is a valid record status in this module (attendance-workspace.ts counts `status === 'present' || status === 'late'` as checked-in, and EMPTY_SUMMARY includes `late`), but STATUS_META has no 'late' entry. A 'late' record rendered by AttendanceStatusTag falls into the fallback branch and displays the raw English string 'late' with className `is-absent` and short '?', visually treating a late student as absent. Add a `late` entry to STATUS_META (e.g. label '迟到', className 'is-late' which already exists in attendance.css).","suggestion_code":null,"existing_code":"export const STATUS_META: Record<\n string,\n { label: string; color: string; className: string; short: string }\n> = {"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx","start_line":192,"end_line":192,"category":"bug","severity":"high","content":"'late' records are excluded from the attendance rate calculation and from pickPrimaryStatus priority. Since attendance-workspace.ts treats `status === 'late'` as checked-in, a student with only 'late' records will be shown with primaryStatus 'absent' and rate 0%, misrepresenting them as fully absent. Count late as checked-in (consistent with the workspace logic) and add 'late' to the priority list in pickPrimaryStatus.","suggestion_code":"const checked = item.records.filter(\n (record) => record.status === 'present' || record.status === 'late',\n ).length;","existing_code":"const checked = item.records.filter((record) => record.status === 'present').length;"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx","start_line":97,"end_line":99,"category":"bug","severity":"medium","content":"displayAttendanceStatus silently converts 'pending' to 'absent'. As a result, STATUS_META.pending ('待确认') is never reachable through AttendanceStatusTag, and in pickPrimaryStatus a student with only 'pending' records is treated as 'absent' (since pending is normalized to absent before the priority lookup). If 'pending → absent' is an intentional admin-side policy, document it with a comment; otherwise keep pending distinct so unconfirmed attendance is not shown as 缺勤.","suggestion_code":null,"existing_code":"export function displayAttendanceStatus(status?: string | null): string {\n return status === 'pending' || !status ? 'absent' : status;\n}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdmin.helpers.tsx","start_line":10,"end_line":10,"category":"bug","severity":"low","content":"The 'afternoon' period is labeled '晚课' (evening class) with startTime 14:0017:00, which conflicts with both its periodKey 'afternoon' and the later 'evening_study' period labeled '晚自习' (18:3021:00). This looks like a typo — the label should likely be '下午课' (afternoon class) to match the time slot.","suggestion_code":null,"existing_code":" { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true },"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx","start_line":25,"end_line":25,"category":"maintainability","severity":"medium","content":"The `columns` prop is typed as `any[]`, which discards all type safety for the Table columns (and the checklist requires avoiding `any` unless a comment justifies it). Use antd's `TableColumnsType<AttendanceRecordItem>` instead so column definitions remain type-checked against the record shape.","suggestion_code":" columns: TableColumnsType<AttendanceRecordItem>;","existing_code":" columns: any[];"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx","start_line":134,"end_line":134,"category":"maintainability","severity":"medium","content":"This compares against a display label (`'出勤'`) to decide the rendered text. Status display labels are business strings that can easily change, silently breaking the rendering. Compare against a stable status key (e.g., the record's status / STATUS_META key) or add an explicit display-name map instead of relying on label equality.","suggestion_code":null,"existing_code":" {meta.label === '出勤' ? '正常' : meta.label}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx","start_line":149,"end_line":155,"category":"style","severity":"low","content":"Static inline style block — per the checklist inline styles should be avoided except for dynamic values. Move this margin/alignment layout into a CSS class.","suggestion_code":null,"existing_code":" style={{\n marginBottom: 8,\n display: 'flex',\n alignItems: 'center',\n gap: 8,\n flexWrap: 'wrap',\n }}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminWorkspace.tsx","start_line":190,"end_line":190,"category":"maintainability","severity":"low","content":"`rowSelection.onChange` provides `Key[]` (i.e. `string | number`), and blindly casting to `number[]` is an unsafe assumption. Although `rowKey=\"id\"` currently makes keys numeric, a non-numeric key would silently produce broken selection state. Filter/validate the keys or keep the generic `Key[]` type in the handler signature.","suggestion_code":null,"existing_code":" onChange: (keys) => onSelectRecords(keys as number[]),"}
{"path":"apps/admin/src/pages/AiConfig/index.tsx","start_line":264,"end_line":267,"category":"bug","severity":"medium","content":"If `refreshConfig()` (i.e. `refetchConfig()`) rejects — e.g. a transient network error on `/ai/config` right after a successful test — this catch block overwrites the already-recorded successful `testResult` with a failure card saying \"测试请求失败\", even though the connection test itself succeeded. Additionally, `skipNextSyncRef` is set in `refreshConfig` before the fetch, so a failed refetch leaves the flag stuck and the next genuine config sync will be silently skipped. Separate the refresh from the test try/catch, e.g. `await refreshConfig().catch(() => {})`, so a refresh failure doesn't corrupt the test result.","suggestion_code":" const res = await api.post<TestResult>('/ai/config/test', body);\n setTestResult(res);\n // 刷新失败不应覆盖已成功的测试结果\n await refreshConfig().catch(() => {});\n } catch (err: unknown) {","existing_code":" const res = await api.post<TestResult>('/ai/config/test', body);\n setTestResult(res);\n await refreshConfig();\n } catch (err: unknown) {"}
{"path":"apps/admin/src/pages/AiConfig/index.tsx","start_line":82,"end_line":83,"category":"security","severity":"medium","content":"This query runs unconditionally even for users without `ai:config:read`, because the `!canRead` early return happens after all hooks. Sensitive config (provider/baseUrl/key state) is fetched from `/ai/config` before the permission gate is reached, and the request is also wasted for unauthorized users. Hoist the `usePermission`/`canRead` computation above the query and gate it with `enabled: canRead`.","suggestion_code":" } = useQuery<AiConfigData | null>({\n queryKey: ['ai', 'config'],\n enabled: canRead,","existing_code":" } = useQuery<AiConfigData | null>({\n queryKey: ['ai', 'config'],"}
{"path":"apps/admin/src/pages/AiConfig/index.tsx","start_line":222,"end_line":223,"category":"bug","severity":"medium","content":"`enabled: true` is hardcoded on every save. There is no `enabled` control anywhere on this page, so if the AI feature was deliberately disabled (e.g. via DB or another admin surface), simply saving any change on this page silently re-enables it. Preserve the existing state (`enabled: config?.enabled ?? true`) or add an explicit enable/disable control so a disabled config isn't accidentally turned back on.","suggestion_code":" defaultModel: defaultModel || undefined,\n enabled: config?.enabled ?? true,","existing_code":" defaultModel: defaultModel || undefined,\n enabled: true,"}
{"path":"apps/admin/src/pages/AiConfig/index.tsx","start_line":169,"end_line":171,"category":"bug","severity":"low","content":"This `try` also wraps `form.validateFields(...)`. When validation fails (e.g. missing baseUrl for OPENAI_COMPATIBLE), the rejection is reported as \"获取模型列表失败\" — a misleading error toast that blames the model fetch. Run `validateFields` before entering the try/catch (or distinguish validation failures) so users see the actual field errors instead of a fake fetch error.","suggestion_code":" setFetchingModels(true);\n try {\n // validateFields 失败会进入 catch被误报为“获取模型列表失败”\n await form.validateFields(['provider', 'baseUrl']);","existing_code":" setFetchingModels(true);\n try {\n await form.validateFields(['provider', 'baseUrl']);"}
{"path":"apps/admin/src/pages/AiConfig/index.tsx","start_line":176,"end_line":176,"category":"maintainability","severity":"low","content":"The masked-key sentinel `'••••'` is hardcoded in three places in this file (`handleFetchModels`, `handleSave`, `handleTest`) and duplicated in `AiConfigSteps.tsx`. A typo in one spot would silently send the literal mask to the server as a real API key. Extract a shared constant (e.g. `MASKED_API_KEY = '••••'` in helpers) and reuse it.","suggestion_code":null,"existing_code":" if (formKey && formKey !== '••••') body.apiKey = formKey;"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminHeader.tsx","start_line":230,"end_line":235,"category":"maintainability","severity":"medium","content":"Nested ternary in the metric value calculation violates the project rule against nested ternaries and hurts readability: it mixes a string value (`${attendanceRate}%`) with numeric branches and handles three cases inline. Prefer an if/else chain (or a small helper) so each branch is explicit. Also guard `summary.absent`/`summary.pending` with `?? 0` to avoid a potential `NaN` if either field is ever undefined.","suggestion_code":" let value: string | number;\n if (metric.key === 'all') {\n value = `${attendanceRate}%`;\n } else if (metric.key === 'absent') {\n value = (summary.absent ?? 0) + (summary.pending ?? 0);\n } else {\n value = summary[metric.key as keyof AttendanceSummary] ?? 0;\n }","existing_code":" const value =\n metric.key === 'all'\n ? `${attendanceRate}%`\n : metric.key === 'absent'\n ? summary.absent + summary.pending\n : (summary[metric.key as keyof AttendanceSummary] ?? 0);"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx","start_line":64,"end_line":66,"category":"bug","severity":"medium","content":"The async `onSave` here does not `await onSaveAdminRecordCell(...)` and does not propagate rejection. EditableCell's internal save flow (`saveValue`) relies on the onSave promise rejecting on failure so it can show an error and keep the editor open; because this wrapper resolves immediately, the editor closes and shows a success/undo state even when the underlying mutation fails (the parent's `saveAdminRecordCell` also swallows the error, so a failed save looks successful to the user). Await the callback and let the error propagate (the parent should rethrow on failure) so EditableCell's built-in error handling works.","suggestion_code":" onSave={async (next) => {\n await onSaveAdminRecordCell(record, 'status', next);\n }}","existing_code":" onSave={async (next) => {\n onSaveAdminRecordCell(record, 'status', next);\n }}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminColumns.tsx","start_line":59,"end_line":63,"category":"bug","severity":"low","content":"While a correction is in flight via the action-column Segmented (`correctingRecordId === record.id` disables only that Segmented), this status EditableCell for the same record is still enabled. A user can open it and submit a different status concurrently, producing two overlapping mutations on the same record that can race (optimistic patch vs. server result). Consider also disabling the cell while that record is being corrected, e.g. `disabled={!canEdit || correctingRecordId === record.id}`, destructuring `correctingRecordId` from the context.","suggestion_code":" options={ADMIN_CORRECTION_OPTIONS.map((item) => ({\n value: String(item.value),\n label: item.label,\n }))}\n disabled={!canEdit || correctingRecordId === record.id}","existing_code":" options={ADMIN_CORRECTION_OPTIONS.map((item) => ({\n value: String(item.value),\n label: item.label,\n }))}\n disabled={!canEdit}"}
{"path":"apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx","start_line":310,"end_line":311,"category":"bug","severity":"medium","content":"Edit controls remain enabled after the lesson is settled: when `session.status === 'completed'` the Alert says the lesson has been settled, but the per-row 已打卡/未打卡 buttons (and the 全部已打卡/全部未打卡 batch buttons) are still clickable. The API rejects updates to settled records, so the user will hit an avoidable error (individual edits even cause an optimistic update + rollback). Disable/hide editing when `completed` — e.g. render only `<AttendanceStatus>` when `completed || !canEditAttendance` and disable the batch buttons while `completed`.","suggestion_code":" if (!canEditAttendance || completed) return <AttendanceStatus status={value} />;\n const checkedIn = value === 'present' || value === 'late';","existing_code":" if (!canEditAttendance) return <AttendanceStatus status={value} />;\n const checkedIn = value === 'present' || value === 'late';"}
{"path":"apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx","start_line":191,"end_line":191,"category":"bug","severity":"low","content":"`updateRecord` does not respect `cancelledRef`, contradicting the component comment that \"in-flight requests no longer update state or show toasts after unmount\". If an individual update resolves after the drawer is closed or the lesson switched/remounted, it can still call `setRecords` and show `message.error` on a stale instance. Additionally, rapid toggling of the same record can race, and the rollback (`setRecords(... previous)`) may restore a stale status. Guard after the await (e.g. `if (cancelledRef.current) return;`) before updating state/toast, and consider ignoring errors when cancelled.","suggestion_code":null,"existing_code":" await api.put(`/attendance-records/${record.id}`, { status });"}
{"path":"apps/admin/src/pages/Attendance/LessonAttendanceDetail.tsx","start_line":101,"end_line":103,"category":"maintainability","severity":"low","content":"Business status literals ('present'/'late'/'absent') and API endpoint paths ('/attendance-records/batch-status', '/attendance-records/${id}', '/attendance-lessons/schedules/.../pull') are hardcoded and repeated in several places (batch filter, button handlers, update calls). Extract them into shared constants/helpers so a backend enum change or a typo can be fixed in one place.","suggestion_code":null,"existing_code":" status === 'present'\n ? record.status !== 'present' && record.status !== 'late'\n : record.status !== 'absent',"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":487,"end_line":496,"category":"maintainability","severity":"medium","content":"Nested ternary (a three-way chained conditional) violates the project rule that nested ternary expressions are not allowed and hurts readability. Refactor to explicit if/else branches.","suggestion_code":" const subjectTeacherNames = (() => {\n if (currentSchedule) {\n return getTeacherDisplayName({\n name: currentSchedule.teacherName,\n username: currentSchedule.teacherUsername,\n });\n }\n if (classId) {\n return formatTeacherNames(\n overviewTeachers.filter((teacher) => teacher.roleType === 'subject_teacher'),\n );\n }\n return '请选择班级';\n })();","existing_code":" const subjectTeacherNames = currentSchedule\n ? getTeacherDisplayName({\n name: currentSchedule.teacherName,\n username: currentSchedule.teacherUsername,\n })\n : classId\n ? formatTeacherNames(\n overviewTeachers.filter((teacher) => teacher.roleType === 'subject_teacher'),\n )\n : '请选择班级';"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":409,"end_line":410,"category":"maintainability","severity":"medium","content":"Nested ternary used for the status label violates the no-nested-ternary rule. Extract a status → label map instead for clarity and to avoid silently treating any unknown status as 「缺勤」.","suggestion_code":" const statusLabelMap: Record<string, string> = {\n present: '正常',\n leave: '请假',\n absent: '缺勤',\n };\n const statusLabel = statusLabelMap[nextStatus] ?? '未知';","existing_code":" const statusLabel =\n nextStatus === 'present' ? '正常' : nextStatus === 'leave' ? '请假' : '缺勤';"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":346,"end_line":354,"category":"maintainability","severity":"low","content":"Per project async conventions, prefer async/await over Promise chains. Also consider checking the response content-type / error body so a non-OK export (e.g. 401/500 returning JSON) shows a more accurate error.","suggestion_code":" try {\n const response = await fetch(`/api/attendance-records/export?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n });\n if (!response.ok) throw new Error('导出失败');\n const blob = await response.blob();\n saveAs(blob, `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`);\n } catch {\n message.error('导出失败');\n }","existing_code":" fetch(`/api/attendance-records/export?${params.toString()}`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n .then((response) => {\n if (!response.ok) throw new Error('导出失败');\n return response.blob();\n })\n .then((blob) => saveAs(blob, `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`))\n .catch(() => message.error('导出失败'));"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":501,"end_line":508,"category":"performance","severity":"low","content":"`buildAttendanceAdminColumns` is called on every render, producing a new `columns` array identity each time and forcing the table to re-render/re-measure even when nothing relevant changed. Memoize the columns (and make the `saveAdminRecordCell`/`updateAdminRecordStatus` handlers stable with useCallback) so the memo is effective.","suggestion_code":" const columns = useMemo(\n () =>\n buildAttendanceAdminColumns({\n isMobile,\n canEdit,\n sessionMap,\n correctingRecordId,\n onSaveAdminRecordCell: saveAdminRecordCell,\n onUpdateAdminRecordStatus: updateAdminRecordStatus,\n }),\n [isMobile, canEdit, sessionMap, correctingRecordId, saveAdminRecordCell, updateAdminRecordStatus],\n );","existing_code":" const columns = buildAttendanceAdminColumns({\n isMobile,\n canEdit,\n sessionMap,\n correctingRecordId,\n onSaveAdminRecordCell: saveAdminRecordCell,\n onUpdateAdminRecordStatus: updateAdminRecordStatus,\n });"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":584,"end_line":587,"category":"bug","severity":"low","content":"When the user changes `pageSize`, the current `page` is preserved unchanged. If the smaller page size yields fewer pages than the current page, the query can land on an empty/out-of-range page with no data. Reset `page` to 1 when `pageSize` changes.","suggestion_code":" onPageChange={(nextPage, nextPageSize) => {\n if (nextPageSize !== pageSize) {\n setPage(1);\n } else {\n setPage(nextPage);\n }\n setPageSize(nextPageSize);\n }}","existing_code":" onPageChange={(nextPage, nextPageSize) => {\n setPage(nextPage);\n setPageSize(nextPageSize);\n }}"}
{"path":"apps/admin/src/pages/Attendance/admin.tsx","start_line":323,"end_line":323,"category":"performance","severity":"low","content":"`refreshDingTalkMutation` is configured with `invalidate: [['attendance','records'], ['attendance','sync-status']]`, which already refetches the active records/sync-status queries on success. The explicit `loadRecords()` / `loadSyncStatus()` here therefore issues duplicate network requests. Keep only one mechanism (prefer the invalidate config) to avoid double fetching.","suggestion_code":" // no manual refetch needed: the mutation's invalidate already refetches records & sync-status","existing_code":" await Promise.all([loadRecords(), loadSyncStatus()]);"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx","start_line":157,"end_line":158,"category":"performance","severity":"low","content":"`sortAttendanceRecords(student.records)` is invoked twice in the same render (here in the rates section and again in the timeline section). The sort is recomputed on every render, including after each status correction (when `correctingRecordId` changes). Compute it once before the return (e.g. `const sortedRecords = sortAttendanceRecords(student.records);`) and reuse it in both `.map()` calls to avoid duplicate work.","suggestion_code":" <section className=\"student-detail-rates\">\n {sortedRecords.map((record) => {","existing_code":" <section className=\"student-detail-rates\">\n {sortAttendanceRecords(student.records).map((record) => {"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx","start_line":137,"end_line":137,"category":"bug","severity":"low","content":"A string width ('100%') is passed to Drawer's `size` prop, which elsewhere in this project is used with numeric widths (e.g. `size={680}`, `size={960}`). antd's `size` is meant for preset values / numeric width; passing an arbitrary string may be ignored or fall back to the default width (378px), so on mobile the drawer may not actually be full-screen. Use the `width` prop (which accepts `string | number`) for the percentage case: `width={isMobile ? '100%' : 520}`.","suggestion_code":" width={isMobile ? '100%' : 520}","existing_code":" size={isMobile ? '100%' : 520}"}
{"path":"apps/admin/src/pages/Attendance/AttendanceAdminModals.tsx","start_line":174,"end_line":174,"category":"bug","severity":"low","content":"Here `displayAttendanceStatus` maps 'pending' records to 'absent', so in this detail/correction drawer a pending (待确认) record is shown as 缺勤 and the correction `Segmented` preselects 缺勤 as if it were already the current value. This is misleading in a correction UI: the parent's `updateAdminRecordStatus` only treats a selection as a no-op when the raw status is not 'pending', so clicking the already-highlighted 缺勤 option will silently commit a pending record to absent. Consider displaying the raw `record.status` (and showing a pending state) in this drawer so admins can distinguish 待确认 records before correcting them.","suggestion_code":null,"existing_code":" <AttendanceStatusTag status={displayAttendanceStatus(record.status)} />"}
{"path":"apps/admin/src/pages/Attendance/index.tsx","start_line":9,"end_line":12,"category":"bug","severity":"high","content":"`useMemo(readCurrentRoles, [])` reads the store only once at first render via `getState()` and never subscribes to updates. `userStore.user` is initialized as `null` and populated only after login/persist rehydration, so if this page mounts before the user data is available (or after role changes/logout), `roles` stays `[]` and `experience` is permanently stale — e.g. an academic admin (`教务管理员`) without an explicit `attendance:edit` permission would be wrongly routed to the teacher workspace. Use a zustand selector instead so the component subscribes and recomputes when roles change:\n\n```tsx\nconst roles = useUserStore((state) =>\n Array.isArray(state.user?.roles) ? (state.user!.roles as string[]) : [],\n);\n```","suggestion_code":"const roles = useUserStore((state) =>\n Array.isArray(state.user?.roles) ? (state.user!.roles as string[]) : [],\n);","existing_code":"function readCurrentRoles(): string[] {\n const roles = useUserStore.getState().user?.roles;\n return Array.isArray(roles) ? roles : [];\n}"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":72,"end_line":72,"category":"maintainability","severity":"medium","content":"`EditableRentalCell` is declared inside the `RentalTable` component and used as a JSX element (`<EditableRentalCell .../>`), so React treats it as a component type whose identity changes on every render. Any re-render of `RentalTable` (e.g., each `setContractPercent` progress update during an upload) unmounts/remounts the whole cell subtree, which can interrupt an in-progress inline edit and drop input focus, and defeats memoization. Either invoke it as a plain render function (`EditableRentalCell({...})`) or move it outside the component, passing `onSaveCell`/`hasPermission` via props.","suggestion_code":null,"existing_code":" const EditableRentalCell = <R extends { id: number; effectiveStatus?: string }>({"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":266,"end_line":268,"category":"bug","severity":"medium","content":"On upload failure only `onError` is invoked. Because the `Upload` uses `showUploadList={false}`, antd will not render any error UI, so the user receives no feedback when a contract upload fails. Add a user-friendly `message.error(...)` in the catch block.","suggestion_code":null,"existing_code":" } catch (e) {\n onError?.(e as Error);\n } finally {"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":32,"end_line":33,"category":"maintainability","severity":"low","content":"Pervasive `any` usage (props `data`/`classrooms`/`organizations`, all render params) without justification comments. This hides field-name mistakes (see the `record.status` vs `record.effectiveStatus` mixing in the action column). Define typed interfaces such as `Rental`, `Classroom`, `Organization` and use them in the props and render callbacks.","suggestion_code":null,"existing_code":"export interface RentalTableProps {\n data: any[];"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":189,"end_line":189,"category":"bug","severity":"low","content":"If `startDate`/`endDate` is missing or an invalid date string, `dayjs(...).diff(...)` returns `NaN` and the cell renders \"NaN天\". Guard against invalid/missing dates and fall back to '-' before computing the duration.","suggestion_code":null,"existing_code":" const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":260,"end_line":261,"category":"bug","severity":"low","content":"`uploadingContractId` and `contractPercent` are single shared states for the whole table. If a second upload starts before the first finishes (or two rows are uploaded concurrently), the second call overwrites the first, so the first row's loading/progress display is wrong and its state is cleared prematurely. Track progress per contract id (e.g., a `Record<number, number>` map) or prevent starting another upload while one is in progress.","suggestion_code":null,"existing_code":" setUploadingContractId(r.id);\n setContractPercent(0);"}
{"path":"apps/admin/src/pages/ClassroomRentals/RentalTable.tsx","start_line":327,"end_line":327,"category":"bug","severity":"low","content":"All other row actions are gated on `record.effectiveStatus`, but the delete button checks `record.status === 'cancelled'`. If the persisted `status` field isn't updated to 'cancelled' when a rental is cancelled (only `effectiveStatus` changes), this button never appears; if the two fields can diverge, the visibility logic is inconsistent. Use the same field used by the sibling conditions for consistency.","suggestion_code":null,"existing_code":" {record.status === 'cancelled' && canPurgeRental ? ("}
{"path":"apps/admin/src/pages/Classes/detail.tsx","start_line":65,"end_line":68,"category":"bug","severity":"medium","content":"The detail query does not guard against an undefined `id`, while the sibling schedule/attendance queries do (`if (!id) return ...`). If the route param is missing, this sends a request to `/classes/undefined`. Add `enabled: Boolean(id)` to the query options or an `if (!id)` guard in the queryFn for consistency and robustness.","suggestion_code":null,"existing_code":" queryFn: async () => {\n const res = (await api.get(`/classes/${id}`)) as ClassDetail;\n return { detail: res, students: res.students || [], teachers: res.teachers || [] };\n },"}
{"path":"apps/admin/src/pages/Classes/detail.tsx","start_line":93,"end_line":95,"category":"performance","severity":"low","content":"The schedule and attendance-summary queries are executed immediately on page mount (and on every date-range change) even when the user never opens those tabs, producing wasted network requests. Consider lazy-loading them only when the corresponding tab is active (e.g. track the active tab key and use `enabled`, or fetch inside the tab component).","suggestion_code":null,"existing_code":" queryKey: ['classes', 'schedule', id, scheduleDateRange],\n queryFn: async () => {\n if (!id) return [];"}
{"path":"apps/admin/src/pages/Classes/detail.tsx","start_line":96,"end_line":99,"category":"maintainability","severity":"low","content":"The date-range-to-query-params conversion is duplicated in the schedule and attendance-summary queryFns. Extract a small helper (e.g. `buildDateRangeParams(start, end)`) to avoid duplication and keep both queries in sync.","suggestion_code":null,"existing_code":" const params: Record<string, string> = {};\n if (scheduleDateRange?.[0]) params.startDate = scheduleDateRange[0].format('YYYY-MM-DD');\n if (scheduleDateRange?.[1]) params.endDate = scheduleDateRange[1].format('YYYY-MM-DD');\n return (await api.get<ClassScheduleItem[]>(`/classes/${id}/schedule`, { params })) || [];"}
{"path":"apps/admin/src/pages/Classes/detail.tsx","start_line":125,"end_line":126,"category":"bug","severity":"low","content":"`editForm.validateFields()` rejects on validation errors, which are caught by the same catch block that reports '更新失败' via `message.error`. This surfaces a validation failure as a server/update failure to the user. Separate the form validation from the API call (e.g. return early on validation failure or use distinct catch blocks).","suggestion_code":null,"existing_code":" const values = await editForm.validateFields();\n await api.put(`/classes/${id}`, {"}
{"path":"apps/admin/src/pages/Classes/detail.tsx","start_line":230,"end_line":232,"category":"style","severity":"low","content":"Static inline style `style={{ padding: 24 }}` should be moved to a CSS class / CSS module per the project's inline-style guideline (inline styles should only be used for dynamic values).","suggestion_code":null,"existing_code":" <div style={{ padding: 24 }}>\n <Skeleton active paragraph={{ rows: 8 }} />\n </div>"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":257,"end_line":259,"category":"security","severity":"medium","content":"`document.write()` is used to render the print window content, which the review rules prohibit (page reflow + security risk). Prefer navigating the opened window to a Blob URL or assigning `printWindow.document.body.innerHTML` instead of calling `document.write()`.","suggestion_code":" try {\n const bill = await api.get<BillPrintData>(`/bills/${billId}`);\n const blob = new Blob([buildBillPrintHtml(bill)], { type: 'text/html;charset=utf-8' });\n const url = URL.createObjectURL(blob);\n printWindow.location.href = url;\n window.setTimeout(() => URL.revokeObjectURL(url), 60_000);\n } catch (error: any) {","existing_code":" printWindow.document.write(\n '<p style=\"font-family:sans-serif;padding:24px\">正在加载账单...</p>',\n );"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":279,"end_line":285,"category":"bug","severity":"medium","content":"These renderers call `v.toFixed(2)` without a null/undefined guard, while the adjacent `paidAmount`/`outstandingAmount`/`walletBalance` columns use `(value ?? 0)`. If the API omits these fields, the table cell throws `Cannot read properties of undefined (reading 'toFixed')` and breaks rendering. The same unguarded pattern exists in the detail modal for `roomTotalAmount` and `studentAmount`. Use `Number(v || 0).toFixed(2)` consistently.","suggestion_code":" render: (v: number) => `¥${Number(v || 0).toFixed(2)}`,","existing_code":" {\n title: '分摊费用',\n dataIndex: 'sharedAmount',\n width: 120,\n align: 'right' as const,\n render: (v: number) => `¥${v.toFixed(2)}`,\n },"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":61,"end_line":61,"category":"maintainability","severity":"medium","content":"Heavy use of `any` (the `detailModal` state, `res` in `handleGenerate`/`showDetail`, `(b: any)` in `filteredBills`, and `(_: any, r: any)` in column renders) defeats type safety. Since `billsSchema` already exists, define a `Bill` interface (or infer from the schema) and type the state, query result, and render callbacks accordingly, so null/optional fields are enforced by the compiler instead of relying on optional chaining at runtime.","suggestion_code":" const [detailModal, setDetailModal] = useState<Bill | null>(null);","existing_code":" const [detailModal, setDetailModal] = useState<any>(null);"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":47,"end_line":54,"category":"bug","severity":"low","content":"The expense-type filter offers `rent` (租金), but `typeMap` has no `rent` entry. Bills whose detail items use `expenseType: 'rent'` will render the raw English value `rent` in the detail table instead of `租金`. Add `rent: '租金',` to keep the map consistent with the filter options.","suggestion_code":"const typeMap: Record<string, string> = {\n water: '水费',\n electricity: '电费',\n cleaning: '保洁费',\n rent: '租金',\n damage: '损坏赔偿',\n penalty: '罚款',\n other: '其他',\n};","existing_code":"const typeMap: Record<string, string> = {\n water: '水费',\n electricity: '电费',\n cleaning: '保洁费',\n damage: '损坏赔偿',\n penalty: '罚款',\n other: '其他',\n};"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":152,"end_line":156,"category":"bug","severity":"low","content":"Potential stale-response race: if the user opens the detail modal for bill A (still loading) and then opens bill B, the earlier A response can arrive last and overwrite the modal with the wrong bill. Guard with a request sequence (e.g., a `useRef` counter or an AbortController) so only the latest request's result is applied.","suggestion_code":" const showDetail = useCallback(async (id: number) => {\n const seq = ++detailSeq.current;\n setDetailLoading(true);\n try {\n const res = await api.get(`/bills/${id}`);\n if (seq === detailSeq.current) setDetailModal(res);","existing_code":" const showDetail = useCallback(async (id: number) => {\n setDetailLoading(true);\n try {\n const res = await api.get(`/bills/${id}`);\n setDetailModal(res);"}
{"path":"apps/admin/src/pages/Bills/index.tsx","start_line":304,"end_line":306,"category":"style","severity":"low","content":"Static inline styles (e.g. fixed color values in the table/detail renders) are used in several places; the review rules ask to avoid inline `style` except for dynamic values. Move static colors/font styles into CSS classes (or the component stylesheet) for consistency and maintainability.","suggestion_code":null,"existing_code":" render: (value: number) => (\n <span style={{ color: '#389e0d' }}>¥{(value ?? 0).toFixed(2)}</span>\n ),"}
{"path":"apps/admin/src/pages/Attendance/teacher.tsx","start_line":130,"end_line":131,"category":"bug","severity":"low","content":"During the initial load (isPending) and while the spinner is active, `schedules` is still empty so the '今天还没有课程' empty card is rendered behind the Spin mask. A user can briefly see a misleading no-courses message before data arrives. Guard the empty branch with `!loading` so the empty state only appears after the fetch settles and truly returns no schedules.","suggestion_code":" ) : !loading && schedules.length === 0 ? (\n <Card className=\"attendance-empty-card\">","existing_code":" ) : schedules.length === 0 ? (\n <Card className=\"attendance-empty-card\">"}
{"path":"apps/admin/src/pages/Attendance/teacher.tsx","start_line":66,"end_line":68,"category":"bug","severity":"medium","content":"`now` is captured once per render and there is no timer or refetch interval, so the phase logic (`startedCount`, `nextSchedule`, and each `LessonCard`'s status) goes stale while the page stays open. A course whose start time passes while the teacher is viewing the page will never transition from '待上课/等待上课' to '进行中/查看当前考勤' until the user manually clicks the refresh button. Consider adding `refetchInterval` to the query (e.g. every 60s) or a minute-level ticking state so phase transitions and KPIs self-update.","suggestion_code":null,"existing_code":" const now = new Date();\n const schedules = workspace?.todaySchedules ?? [];\n const startedCount = schedules.filter((item) =>"}
{"path":"apps/admin/src/pages/Attendance/teacher.tsx","start_line":71,"end_line":73,"category":"bug","severity":"low","content":"`nextSchedule` (and the timeline order) relies on the backend returning `todaySchedules` already sorted by time. If the response is not chronologically ordered, '下一节' may point to a later class while an earlier upcoming/ongoing one exists, and the timeline sequence would be out of order. Sort by `startTime` before picking the next schedule to make the KPI robust regardless of API ordering.","suggestion_code":" const nextSchedule = [...schedules]\n .sort((a, b) => a.startTime.localeCompare(b.startTime))\n .find((item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended');","existing_code":" const nextSchedule = schedules.find(\n (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',\n );"}
{"path":"apps/admin/src/pages/Classes/ClassDetailTabs.tsx","start_line":314,"end_line":314,"category":"maintainability","severity":"medium","content":"The roster export re-implements download logic already provided by ../../utils/download.ts (downloadBlob) and bypasses the centralized axios api wrapper used everywhere else in this codebase. It hardcodes the /api prefix and the endpoint path, manually reads the token without the token guard that the axios interceptor applies (a missing token would send an invalid Authorization header), and misses the 401 auto-logout handling. Consider using downloadBlob('/classes/<id>/roster/export', filename) and keeping the try/catch for the user-facing message.","suggestion_code":null,"existing_code":"if (!res.ok) throw new Error('导出失败');"}
{"path":"apps/admin/src/pages/Classes/ClassDetailTabs.tsx","start_line":240,"end_line":241,"category":"bug","severity":"low","content":"id is declared optional (id?: string) but is interpolated into the export URL and the fallback filename. When undefined (e.g., missing route param), the request goes to /api/classes/undefined/roster/export and the file is named 班级花名册-undefined.xlsx without a clear error. Make id a required prop or guard the export with an early return/error message when id is falsy.","suggestion_code":null,"existing_code":"export const ClassStudentsTab: React.FC<{\n id?: string;"}
{"path":"apps/admin/src/pages/Classes/ClassDetailTabs.tsx","start_line":517,"end_line":521,"category":"maintainability","severity":"low","content":"The DatePicker.RangePicker block and the table pagination config (defaultPageSize: 20, showSizeChanger, pageSizeOptions) are duplicated between ClassScheduleTab and ClassAttendanceTab. Consider extracting a shared date-range picker component or a common pagination config constant to avoid drift.","suggestion_code":null,"existing_code":"<DatePicker.RangePicker\n value={scheduleDateRange}\n onChange={(dates) => onRangeChange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}\n placeholder={['开始日期', '结束日期']}\n />"}
{"path":"apps/admin/src/pages/Classes/ClassDetailTabs.tsx","start_line":299,"end_line":299,"category":"style","severity":"low","content":"Static layout spacing is applied via inline style attributes (marginBottom/marginRight). Per the project's inline-style rule, prefer class names/CSS for static spacing and reserve inline styles for dynamic values.","suggestion_code":null,"existing_code":"style={{ marginBottom: 16, marginRight: 8 }}"}
{"path":"apps/admin/src/pages/AttendanceDevices.tsx","start_line":119,"end_line":121,"category":"bug","severity":"medium","content":"`openEdit` does not call `form.resetFields()` before `setFieldsValue`, unlike `openCreate`. Since the Modal keeps the Form mounted across open/close (no `destroyOnHidden`), validation errors/touched state from a previous failed create/edit attempt will still be displayed when reopening the modal in edit mode. Call `form.resetFields()` (or `form.clearValidate()`) before setting the values.","suggestion_code":" const openEdit = (record: AttendanceDeviceRow) => {\n setEditing(record);\n form.resetFields();\n form.setFieldsValue({","existing_code":" const openEdit = (record: AttendanceDeviceRow) => {\n setEditing(record);\n form.setFieldsValue({"}
{"path":"apps/admin/src/pages/AttendanceDevices.tsx","start_line":133,"end_line":135,"category":"bug","severity":"low","content":"`form.validateFields()` is awaited outside the try/catch. When validation fails, the rejected promise propagates out of `handleSave` (the `onOk` handler) as an unhandled rejection instead of being handled here. Wrap validation in its own try/catch and return early on failure (antd Form already shows the inline field errors).","suggestion_code":" const handleSave = async () => {\n let values;\n try {\n values = await form.validateFields();\n } catch {\n return; // 校验失败,错误已由 Form 内联展示\n }\n setSaving(true);","existing_code":" const handleSave = async () => {\n const values = await form.validateFields();\n setSaving(true);"}
{"path":"apps/admin/src/pages/AttendanceDevices.tsx","start_line":383,"end_line":388,"category":"maintainability","severity":"low","content":"Duplicate business logic: the status option array `[{value:'active'...},{value:'disabled'...}]` is defined twice (modal Select and table column), and classroom-option formatting is computed in two places with inconsistent label formats (`名称(楼栋)` in `classroomOptions` vs `楼栋 · 名称` in the 绑定教室 column). Extract shared constants/helpers (e.g., `STATUS_OPTIONS` and a `formatClassroomOption` function) so the two views can't drift.","suggestion_code":null,"existing_code":" <Select\n options={[\n { value: 'active', label: '启用' },\n { value: 'disabled', label: '停用' },\n ]}\n />"}
{"path":"apps/admin/src/pages/AttendanceDevices.tsx","start_line":194,"end_line":194,"category":"style","severity":"low","content":"Static inline `style` attributes are used in several places (flex container div, `fontFamily: 'monospace'` span, two `color: '#999'` placeholders). Per the review rules these should be moved to a stylesheet/className unless they are dynamic styles.","suggestion_code":null,"existing_code":" <span style={{ fontFamily: 'monospace' }}>{value}</span>"}
{"path":"apps/admin/src/pages/AttendanceDevices.tsx","start_line":73,"end_line":73,"category":"performance","severity":"low","content":"`loading = isLoading || isFetching` makes the entire table show a loading overlay on every background refetch (e.g., after each inline cell save or delete), causing visible flicker while the user is interacting with rows. Consider using only `isLoading` for the spinner (background refetches keep showing the existing rows) or a less intrusive loading indicator.","suggestion_code":null,"existing_code":" const loading = isLoading || isFetching;"}
{"path":"apps/admin/src/pages/Classrooms/index.tsx","start_line":223,"end_line":225,"category":"bug","severity":"medium","content":"Response status is not checked before saving the blob. If the server returns an error (e.g. 401/500, or a JSON error body), `res.blob()` still resolves and the error body is saved as a corrupt `教室导入模板.xlsx`, and no failure message is shown. Check `res.ok` (or `res.status`) before converting to a blob.","suggestion_code":" fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } })\n .then((res) => {\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n return res.blob();\n })\n .then((blob) => saveAs(blob, '教室导入模板.xlsx'))","existing_code":" fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } })\n .then((res) => res.blob())\n .then((blob) => saveAs(blob, '教室导入模板.xlsx'))"}
{"path":"apps/admin/src/pages/Classrooms/index.tsx","start_line":479,"end_line":483,"category":"bug","severity":"medium","content":"Two issues here: (1) Same as the template download — `r.ok` is never checked, so error responses (401/500) get saved as a bogus `教室使用报表.xlsx` instead of showing an error; (2) In dev mode this uses `/api` (relative to the Vite origin) while `handleDownloadTemplate` uses `http://localhost:${VITE_API_PORT || 3002}/api`. Unless a dev proxy is configured for `/api`, the export silently fails in dev. The base URL logic is duplicated between the two download flows and should be extracted into one helper that also validates the response.","suggestion_code":" fetch(`${baseURL}/classrooms/export`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n .then((r) => {\n if (!r.ok) throw new Error(`HTTP ${r.status}`);\n return r.blob();\n })\n .then((b) => saveAs(b, '教室使用报表.xlsx'))","existing_code":" fetch(`${baseURL}/classrooms/export`, {\n headers: { Authorization: `Bearer ${token}` },\n })\n .then((r) => r.blob())\n .then((b) => saveAs(b, '教室使用报表.xlsx'))"}
{"path":"apps/admin/src/pages/Classrooms/index.tsx","start_line":66,"end_line":66,"category":"maintainability","severity":"low","content":"`any` is used extensively (`editing`, `useQuery<any[]>`, `saveCellMutation` params, column render types). This defeats type checking — e.g. `editing.id`, `record.effectiveStatus`, `r.status === 'archived'` are all untyped. Define a `Classroom` interface (status/effectiveStatus/currentUsage are already partially modeled in `CurrentUsage`/`statusMap`) and replace `any` with it, or add a comment justifying each `any`.","suggestion_code":" const [editing, setEditing] = useState<Classroom | null>(null);","existing_code":" const [editing, setEditing] = useState<any>(null);"}
{"path":"apps/admin/src/pages/Classrooms/index.tsx","start_line":497,"end_line":498,"category":"bug","severity":"low","content":"`res.message` may be undefined if the backend returns a successful response without a message, which would show a toast of \"undefined\". Guard with a fallback message.","suggestion_code":" const res: any = await importMutation.mutateAsync(formData);\n message.success(res?.message ?? '导入成功');","existing_code":" const res: any = await importMutation.mutateAsync(formData);\n message.success(res.message);"}
{"path":"apps/admin/src/pages/Classrooms/index.tsx","start_line":418,"end_line":418,"category":"performance","severity":"low","content":"`useDirtyGuard` returns a fresh object literal `{ confirmClose, snapshot, isDirty }` on every render (the callbacks inside are stable, but the object identity is not). Because `formGuard` is in the `useMemo` dependency array, the `columns` memo is invalidated on every render, defeating the memo and re-creating all `EditableCell` render closures on each render. Destructure the stable callbacks (e.g. `const { confirmClose, snapshot } = useDirtyGuard(form)`) and depend on those instead.","suggestion_code":" [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form, confirmClose, snapshot],","existing_code":" [handlePurge, hasPermission, saveCell, handleArchive, handleRestore, form, formGuard],"}
{"path":"apps/admin/src/pages/ClassroomSchedule/index.tsx","start_line":73,"end_line":77,"category":"bug","severity":"medium","content":"Occupancy rate math is inconsistent: `total` counts every classroom (`classrooms.length * days`) but `rented` only sums classrooms present in `summary`. The per-row fallback `data.summary[c.id] || { rentedDays: 0, ... }` shows `summary` may legitimately omit classrooms without bookings, in which case `rented`/`rate` here are understated while the per-row numbers are not. Iterate over `data.classrooms` and accumulate `data.summary[c.id]?.rentedDays ?? 0` so numerator and denominator are consistent.","suggestion_code":null,"existing_code":" let rented = 0;\n const total = data.classrooms.length * data.days;\n for (const cid of Object.keys(data.summary)) {\n rented += data.summary[+cid].rentedDays;\n }"}
{"path":"apps/admin/src/pages/ClassroomSchedule/index.tsx","start_line":168,"end_line":168,"category":"style","severity":"low","content":"Nested ternary is prohibited by the review rules. Extract a helper (e.g. `function rateColor(rate: number) { if (rate > 70) return '#cf1322'; if (rate > 40) return '#fa8c16'; return '#3f8600'; }`) and call it here.","suggestion_code":null,"existing_code":" color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600',"}
{"path":"apps/admin/src/pages/ClassroomSchedule/index.tsx","start_line":284,"end_line":289,"category":"style","severity":"low","content":"Nested ternary is prohibited by the review rules. Use the same extracted color helper as the overall statistic (or a small `if/else` function) instead of nesting ternaries inline.","suggestion_code":null,"existing_code":" color:\n sum.occupancyRate > 0.7\n ? '#cf1322'\n : sum.occupancyRate > 0.4\n ? '#fa8c16'\n : '#3f8600',"}
{"path":"apps/admin/src/pages/ClassroomSchedule/index.tsx","start_line":322,"end_line":328,"category":"style","severity":"low","content":"Nested ternary is prohibited by the review rules. Compute the icon beforehand, e.g. `let icon = null; if (isInternal) icon = <ReadOutlined style={{ fontSize: 12 }} />; else if (cell.hasContract) icon = <FileTextOutlined style={{ fontSize: 12 }} />;` and render `{icon}`.","suggestion_code":null,"existing_code":" {isInternal ? (\n <ReadOutlined style={{ fontSize: 12 }} />\n ) : cell.hasContract ? (\n <FileTextOutlined style={{ fontSize: 12 }} />\n ) : (\n ''\n )}"}
{"path":"apps/admin/src/pages/ClassroomSchedule/index.tsx","start_line":33,"end_line":33,"category":"maintainability","severity":"medium","content":"Excessive `any` usage bypasses type safety: `classrooms: any[]`, `organizations: any[]`, `matrix: Record<number, Record<number, any>>`, plus `useState<any>(null)`, `const res: any`, and `new Map<string, any[]>()` elsewhere in this file. Define proper interfaces for the schedule cell/classroom/organization data (or derive types from the zod schema via z.infer) so missing fields / wrong shapes are caught by the compiler.","suggestion_code":null,"existing_code":" matrix: Record<number, Record<number, any>>;"}
{"path":"apps/admin/src/pages/Classes/index.tsx","start_line":114,"end_line":114,"category":"performance","severity":"medium","content":"`loading = isLoading || isFetching` drives the Table's `loading` prop, so the full-table spinner appears on every background refetch — including right after each inline cell save (saveCellMutation invalidates ['classes'] → refetch) and on page visibility refetch via useVisibleRefetch. This overlays the table during the inline-edit/undo flow and can interfere with user interaction. Consider using only `isLoading` for the initial spinner, and optionally a lighter indicator (e.g., a small loading icon) for background refetches.","suggestion_code":" const loading = isLoading;","existing_code":" const loading = isLoading || isFetching;"}
{"path":"apps/admin/src/pages/Classes/index.tsx","start_line":213,"end_line":215,"category":"maintainability","severity":"low","content":"This catch block catches errors from `form.validateFields()` as well as from `saveMutation.mutateAsync()`, but the comment claims all errors are handled by useApiMutation — that is only true for the mutation. A validation rejection here is swallowed silently (users only see inline field errors). This becomes a real problem if any cross-field validator is added (e.g., endDate >= startDate), where the failure would be silently dropped. Recommend separating the two failure paths, e.g. `catch (e) { if (!(e as any)?.errorFields) throw e; }` or explicitly returning early when validation fails.","suggestion_code":null,"existing_code":" } catch {\n // 错误提示由 useApiMutation 统一处理\n } finally {"}
{"path":"apps/admin/src/pages/Classes/index.tsx","start_line":489,"end_line":494,"category":"bug","severity":"medium","content":"No validation ensures `endDate` is on/after `startDate`; a user can pick an inverted date range and submit it. The API may accept it without server-side checks, storing invalid data. Add a cross-field rule (e.g., a validator on endDate comparing with startDate) or disable endDate dates before startDate in the DatePicker.","suggestion_code":null,"existing_code":" <Form.Item name=\"startDate\" label=\"开班日期\">\n <DatePicker />\n </Form.Item>\n <Form.Item name=\"endDate\" label=\"结课日期\">\n <DatePicker />\n </Form.Item>"}
{"path":"apps/admin/src/pages/Classes/index.tsx","start_line":496,"end_line":496,"category":"bug","severity":"low","content":"The inline '学员' cell editor allows `min={0}` (and displays `maxStudents || '-'`), while the modal form enforces `InputNumber min={1}`. A class whose maxStudents was saved as 0 via the inline editor cannot be opened in the edit modal without triggering a validation error on submit. Align the two constraints to avoid inconsistent data acceptance.","suggestion_code":" <InputNumber min={0} />","existing_code":" <InputNumber min={1} />"}
{"path":"apps/admin/src/pages/Classes/index.tsx","start_line":390,"end_line":394,"category":"style","severity":"low","content":"Multiple static inline styles are used for the toolbar (marginBottom, fixed widths, margins). Per the project rule of avoiding inline styles except for dynamic values, these should be moved into CSS classes — the toolbar already carries a `responsive-toolbar` class that could host the spacing, and the selects could use a shared class instead of duplicated `width: 120`.","suggestion_code":null,"existing_code":" <Space\n style={{ marginBottom: 16 }}\n wrap\n className=\"responsive-toolbar responsive-toolbar--single\"\n >"}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":58,"end_line":61,"category":"bug","severity":"medium","content":"The installment status editor below offers an 'overdue' (逾期) option, but `installmentStatusMap` only defines 'pending' and 'paid'. When a record is set to 'overdue', the Tag in the detail table renders the raw English string 'overdue' with no color. Add an overdue entry to keep the display consistent.","suggestion_code":"export const installmentStatusMap: Record<string, { text: string; color: string }> = {\n pending: { text: '待缴', color: 'orange' },\n paid: { text: '已缴', color: 'green' },\n overdue: { text: '已逾期', color: 'red' },\n};","existing_code":"export const installmentStatusMap: Record<string, { text: string; color: string }> = {\n pending: { text: '待缴', color: 'orange' },\n paid: { text: '已缴', color: 'green' },\n};"}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":412,"end_line":412,"category":"style","severity":"low","content":"Uses non-strict `!=`; the project rules require strict equality. Since `installmentModal` is typed `number | null`, `!== null` is safe and equivalent.","suggestion_code":"open={installmentModal !== null}","existing_code":"open={installmentModal != null}"}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":144,"end_line":144,"category":"bug","severity":"medium","content":"The OK button is disabled when no eligible student is selected (`okButtonProps={{ disabled: effectiveSelectedEligibleIds.length === 0 }}`), but this keyboard shortcut still calls `onBatchCreate` on Enter (e.g., while typing in the amount/notes fields), bypassing that guard. Gate the shortcut on the selection as well.","suggestion_code":"useSubmitShortcut(batchModal && !saving && effectiveSelectedEligibleIds.length > 0, onBatchCreate);","existing_code":"useSubmitShortcut(batchModal && !saving, onBatchCreate);"}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":334,"end_line":337,"category":"maintainability","severity":"low","content":"Uses `item: any` in the installment table render functions (paidDate and status columns). Per project rules `any` should be avoided; type the row with a proper installment type, e.g. `NonNullable<DepositRecord['installments']>[number]` (also apply to the status column's `item: any` below).","suggestion_code":null,"existing_code":" render: (value: string, item: any) => (\n <EditableCell\n value={value}\n editor=\"date\""}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":203,"end_line":203,"category":"maintainability","severity":"low","content":"Casting `eligibleColumns` to `never` masks all type checking and is fragile. Type the `eligibleColumns` prop properly (e.g. `ColumnsType<EligibleStudent>`) instead of forcing a `never` cast at the usage site.","suggestion_code":null,"existing_code":"columns={eligibleColumns as never}"}
{"path":"apps/admin/src/pages/Deposits/DepositModals.tsx","start_line":258,"end_line":258,"category":"style","severity":"low","content":"Many static styles are inlined across this file (widths, margins, paddings, background, flex layouts). Per the review rules, static styles should be extracted to CSS/classNames; inline `style` should be reserved for dynamic values.","suggestion_code":null,"existing_code":"<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>"}
{"path":"apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx","start_line":44,"end_line":48,"category":"maintainability","severity":"medium","content":"The three Card blocks (缺勤 / 待处理账单 / 待退押金) are structurally identical: same header row (icon + arrow), same count/label/status layout, same onClick→go(path) navigation, and the same static inline styles repeated three times. Consider extracting a data-driven shared card, e.g. a config array `[{ key, path, icon, count, label, statusText, tone }]` rendered by a single inner render function (per the 'Inner Components / renderItem' rule). This would remove ~120 duplicated lines and also let the repeated static inline styles (flex container, marginTop, color values) be consolidated into the constants file where the project already centralizes card styles (Dashboard.types.ts).","suggestion_code":null,"existing_code":" <Card\n style={absentCount > 0 ? TODO_CARD_WARN : TODO_CARD_OK}\n styles={{ body: { padding: 16 } }}\n onClick={() => go('/attendance')}\n >"}
{"path":"apps/admin/src/pages/Dashboard/DashboardTodoCards.tsx","start_line":47,"end_line":47,"category":"other","severity":"low","content":"Clicking an antd `Card` uses a plain div-level onClick, which is not keyboard-accessible: keyboard users cannot activate it (no Tab focus / Enter / Space handling) and screen readers get no role or hint that it is interactive navigation. Consider using `role=\"button\"`/`tabIndex`/`onKeyDown` (Enter/Space) or wrapping the card content in a `<Link>` for proper semantics.","suggestion_code":null,"existing_code":"onClick={() => go('/attendance')}"}
{"path":"apps/admin/src/pages/Deposits/DepositTable.tsx","start_line":12,"end_line":12,"category":"maintainability","severity":"medium","content":"The `data` prop is typed as `any[]`, and render callbacks also use `any` (`r: any`, `record: any`), bypassing the type safety the component already has (`DepositRecord` is imported from './DepositModals'). If `any` is truly necessary, add a comment explaining why; otherwise type as `DepositRecord[]` and type the render parameters accordingly.","suggestion_code":null,"existing_code":"data: any[];"}
{"path":"apps/admin/src/pages/Deposits/DepositTable.tsx","start_line":142,"end_line":142,"category":"bug","severity":"medium","content":"`rowKey=\"id\"` assumes every row has a numeric `id`, but the render logic explicitly guards `typeof record.id === 'number'` (`hasDeposit`), implying `data` may contain rows without an id (e.g. draft/unpaid records). Such rows get `undefined` as key, which triggers React duplicate-key warnings and unstable row identity. Use a `rowKey` function with a fallback, e.g. `rowKey={(r) => r.id ?? `${r.student?.id}-${r.roomNumber}`}`.","suggestion_code":null,"existing_code":"rowKey=\"id\""}
{"path":"apps/admin/src/pages/Deposits/DepositTable.tsx","start_line":97,"end_line":99,"category":"maintainability","severity":"low","content":"Empty `catch {}` silently swallows failures from `onArchive`/`onPurge`. This only works if the caller always passes a `useApiMutation` wrapper that surfaces errors. If a plain async function is passed, the user gets no feedback while the action fails. Consider logging the error or showing a fallback error message to avoid silent failures.","suggestion_code":null,"existing_code":" } catch {\n // 错误提示由 useApiMutation 统一处理\n }"}
{"path":"apps/admin/src/pages/ClassroomRentals/index.tsx","start_line":245,"end_line":245,"category":"bug","severity":"medium","content":"`form.validateFields()` rejects when validation fails, and this happens *before* the try/catch, so the `handleSave` promise rejects. When save is triggered via the keyboard shortcut (`useSubmitShortcut(modalOpen && !saving, () => handleSave())`) this becomes an unhandled promise rejection (and depending on the antd version, the Modal `onOk` path may also log the rejection). Wrap the validation call in try/catch so validation failures are handled gracefully and don't produce unhandled rejections.","suggestion_code":" let values;\n try {\n values = await form.validateFields();\n } catch {\n // 校验错误已由表单内联提示,无需额外处理\n return;\n }","existing_code":" const values = await form.validateFields();"}
{"path":"apps/admin/src/pages/ClassroomRentals/index.tsx","start_line":42,"end_line":42,"category":"maintainability","severity":"medium","content":"`any` is used in several places (`useState<any>(null)`, `useQuery<any[]>`, `record: any`, `meta` typed as `{ classrooms: any[]; organizations: any[] }`, `params: any`), which defeats type safety and lets runtime shape mismatches (e.g. field renames in the API response) go undetected. Define concrete types such as `RentalRecord`, `Classroom`, `Organization` and type the query results, component props and state accordingly.","suggestion_code":" const [editing, setEditing] = useState<RentalRecord | null>(null);","existing_code":" const [editing, setEditing] = useState<any>(null);"}
{"path":"apps/admin/src/pages/ClassroomRentals/index.tsx","start_line":246,"end_line":246,"category":"bug","severity":"low","content":"The client-side guard (`rangeIncludesUnavailableDate` / `disabledDate`) only covers months already fetched by `loadUnavailableDates` — initially only the current+next month (or startDate+1 in `openEdit`) plus months visited while navigating the picker panels. If a multi-month range is chosen by quick-jumping panels (year view) or an edited rental spans several months, conflicting dates in the unloaded middle months are neither disabled nor caught client-side; the user only learns about them from the server-side conflict error. Consider loading unavailable dates for every month of the selected range (e.g. after `dateRange` is chosen) to keep the UI guard accurate.","suggestion_code":null,"existing_code":" if (rangeIncludesUnavailableDate(values.dateRange)) {"}
{"path":"apps/admin/src/pages/ClassroomRentals/index.tsx","start_line":368,"end_line":370,"category":"style","severity":"low","content":"Static layout styles are inline (container div `style`, `width: 180/110`, etc.). Per the project convention of avoiding inline styles except for dynamic ones, these could be moved to CSS modules/classes for maintainability.","suggestion_code":null,"existing_code":" <div\n style={{\n marginBottom: 16,"}
{"path":"apps/admin/src/pages/Exams/ExamFormModal.tsx","start_line":55,"end_line":55,"category":"style","severity":"low","content":"Static inline style is used for the DatePicker width. Per the project's review rules, inline `style` attributes should be avoided except for dynamic styles. Since this width is constant, prefer a CSS class (e.g., a shared `.full-width` / `.date-picker-full-width` rule) instead of an inline style.","suggestion_code":"<DatePicker className=\"date-picker-full-width\" />","existing_code":"<DatePicker style={{ width: '100%' }} />"}
{"path":"apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx","start_line":32,"end_line":32,"category":"bug","severity":"medium","content":"Layout shift / scroll jump: the placeholder minHeight (340/440/490) is smaller than the actual rendered content height (antd Card header ~56px + body padding ~48px + chart 300/400/450px ≈ 404/504/554px). When the section scrolls into view, the placeholder swaps to the real chart and the page grows by ~60px, pushing content below down and causing a visible jump. Align the placeholder minHeight with the real content height (e.g. heatmap: mobile 404 / desktop 504; gantt: mobile 404 / desktop 554). Additionally, the '加载中…' label is misleading: nothing is loading at that point (the data may already be fetched; the chart is simply not rendered yet), so a neutral placeholder text would be more accurate.","suggestion_code":null,"existing_code":" <Card title={title} style={{ minHeight }}>"}
{"path":"apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx","start_line":54,"end_line":54,"category":"performance","severity":"low","content":"The full ECharts option is rebuilt on every render of the card (e.g. on each parent re-render, refetch, or isFetching toggle), even though `data`/`periodEnd` rarely change. Memoize the option with `useMemo` keyed on `data` (and `periodEnd`) to avoid repeated heavy computation. Same applies to `buildGanttOption` in `GanttCard`.","suggestion_code":null,"existing_code":" option={buildClassroomHeatmapOption(data)}"}
{"path":"apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx","start_line":52,"end_line":52,"category":"bug","severity":"low","content":"`data.some(...)` / `data.length > 0` dereference `data` without a guard. The current callers always pass arrays (defaulting to `[]` after validation), but if any future caller passes `undefined` the whole dashboard section crashes. Use `data?.some(...)` and `(data?.length ?? 0) > 0` for defensive null-safety, per the null-check convention.","suggestion_code":null,"existing_code":" {data.some((r) => Number(r.occupancy) > 0) ? ("}
{"path":"apps/admin/src/pages/Dashboard/DashboardLazyCards.tsx","start_line":58,"end_line":60,"category":"maintainability","severity":"low","content":"The loading placeholder ('加载中…') and the two empty-state blocks ('暂无教室占用数据' / '暂无入住数据') duplicate the same inline-styled markup (`textAlign/padding/color`) across both cards. Extract a small shared `EmptyState`/`Placeholder` component (or at least a shared style constant) to remove duplication and the repeated static inline styles.","suggestion_code":null,"existing_code":" <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>\n 暂无教室占用数据\n </div>"}
{"path":"apps/admin/src/pages/Deposits/index.tsx","start_line":208,"end_line":211,"category":"bug","severity":"medium","content":"搜索框占位符承诺「搜索学生姓名/学号」,但默认列表分支只匹配 `d.student?.name`,没有匹配 `studentNo`;而房型筛选分支同时匹配 `studentName` 和 `studentNo`,两处行为不一致。结果是:未选择房型时按学号搜索已有押金记录搜不到。建议补上学号匹配。","suggestion_code":" if (searchText) {\n const s = searchText.toLowerCase();\n const nameMatched = d.student?.name?.toLowerCase().includes(s);\n const noMatched = d.student?.studentNo?.toLowerCase().includes(s);\n if (!nameMatched && !noMatched) return false;\n }","existing_code":" if (searchText) {\n const s = searchText.toLowerCase();\n if (!d.student?.name?.toLowerCase().includes(s)) return false;\n }"}
{"path":"apps/admin/src/pages/Deposits/index.tsx","start_line":270,"end_line":271,"category":"bug","severity":"medium","content":"`effectiveSelectedEligibleIds` 在 `selectionTouched === false` 时取当前 `eligibleStudents` 全部学生。切换房型(`handleBatchRoomTypeChange`)后会清空选中并异步刷新候选列表,但刷新完成前 `eligibleStudents` 仍是上一房型的过期数据;批量弹窗的确认按钮仅按 `effectiveSelectedEligibleIds.length === 0` 禁用,未考虑 `eligibleLoading`。用户切房型后立即点「确认批量收取」,会把上一房型的学生按新房型金额批量收费,属于数据一致性问题。建议提交时等待候选列表加载完成,或把 `eligibleLoading` 加入提交禁用条件。","suggestion_code":" if (eligibleLoading) {\n message.warning('候选学生列表加载中,请稍候');\n return;\n }\n await batchCreateMutation.mutateAsync({\n studentIds: effectiveSelectedEligibleIds,","existing_code":" await batchCreateMutation.mutateAsync({\n studentIds: effectiveSelectedEligibleIds,"}
{"path":"apps/admin/src/pages/Deposits/index.tsx","start_line":219,"end_line":219,"category":"maintainability","severity":"low","content":"`'四人间'` 与 `amount: 500``openCreateDeposit` 中)等业务默认值在页面内硬编码,与 `DepositModals` 导出的 `roomTypeOptions`/`suggestedDepositByRoomType` 重复。房型或建议金额调整时两处容易不一致,建议复用常量(例如 `suggestedDepositByRoomType['四人间']`),集中定义默认房型常量。","suggestion_code":null,"existing_code":" const openBatchModal = (roomType = filterRoomType || '四人间') => {"}
{"path":"apps/admin/src/pages/Deposits/index.tsx","start_line":349,"end_line":352,"category":"other","severity":"low","content":"`saveInstallmentCell` 成功后再用原生 `api.get` 重拉详情,未经过 `depositsSchema` 校验就 `setDetailModal`,且该重拉被外层 catch 吞掉(与 useApiMutation 的错误提示逻辑无关),失败时详情弹窗静默停留在旧数据。建议复用查询缓存(如 `queryClient.invalidateQueries` 后从缓存读取),或至少校验响应并单独处理重拉失败。","suggestion_code":null,"existing_code":" if (detailModal) {\n const refreshed = await api.get<DepositRecord>(`/deposits/${detailModal.id}`);\n setDetailModal(refreshed);\n }"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":84,"end_line":84,"category":"performance","severity":"medium","content":"Changing the date range updates `queryKey: ['dashboard', period]`, and because no `placeholderData` is configured, the new query starts with no cached data: `isLoading` becomes true and the entire page collapses to the full-page Skeleton before re-rendering every chart. This produces a visible full-page flash on every date-picker change and defeats the careful per-module/partial-failure loading design (the header `refreshLoading` spin never covers query-key switches). Consider `placeholderData: keepPreviousData` so the previous dashboard stays visible while the new period loads.","suggestion_code":" queryKey: ['dashboard', period],\n placeholderData: keepPreviousData,","existing_code":" queryKey: ['dashboard', period],"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":109,"end_line":112,"category":"bug","severity":"low","content":"`partialFailures` only counts rejected HTTP requests; modules whose responses fail schema validation inside `safeValidate` are silently downgraded to fallback values (only logged with `console.error`). As a result the '有 N 项数据加载失败' warning under-reports real failures and users see empty/zero data for those modules with no explanation. Consider counting validation failures into `partialFailures` or surfacing them separately.","suggestion_code":null,"existing_code":" const partialFailures = rejected.length;\n if (partialFailures > 0) {\n console.error('部分看板数据加载失败', rejected);\n }"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":179,"end_line":181,"category":"bug","severity":"low","content":"If the `/expense-types` request fails, this query silently returns `{}` and the expense pie chart falls back to rendering raw type codes with no user-facing error indication (and this module is excluded from the dashboard's partial-failure accounting), so users can't tell why names are missing. Consider folding it into the main `Promise.allSettled`/`partialFailures` flow or showing a fallback label.","suggestion_code":null,"existing_code":" } catch {\n return {};\n }"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":53,"end_line":54,"category":"bug","severity":"low","content":"`Grid.useBreakpoint()` returns an empty object `{}` on the very first render, so `!screens.sm` evaluates to `true` and the page briefly renders with the mobile layout / mobile chart heights before switching to desktop — causing a layout flash on mount. Guard the undefined value, e.g. `const isMobile = !(screens.sm ?? false);`.","suggestion_code":" const screens = Grid.useBreakpoint();\n const isMobile = !(screens.sm ?? false);","existing_code":" const screens = Grid.useBreakpoint();\n const isMobile = !screens.sm;"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":470,"end_line":477,"category":"maintainability","severity":"low","content":"This '暂无…数据' empty-state block (the same `textAlign: 'center', padding: 40, color: '#999'` div) is duplicated 6 times across the chart cards. Extract a small shared component (e.g. `<ChartEmpty text=\"暂无考勤数据\" />`) to eliminate the duplication and keep copy consistent.","suggestion_code":null,"existing_code":" {(stats?.attendanceTrend || []).length > 0 ? (\n <ReactECharts\n option={buildAttendanceLineOption(stats?.attendanceTrend ?? [])}\n style={{ width: '100%', height: isMobile ? 250 : 300 }}\n />\n ) : (\n <div style={{ textAlign: 'center', padding: 40, color: '#999' }}>暂无考勤数据</div>\n )}"}
{"path":"apps/admin/src/pages/Dashboard/index.tsx","start_line":231,"end_line":240,"category":"style","severity":"low","content":"This header container mixes static inline styles (`marginBottom`, `gap`, `display`, `justifyContent`) with breakpoint-dependent ones. The project already extracts shared static styles into constants (`SECTION_ROW_STYLE`, `MARGIN_BOTTOM_16_STYLE`); move the static parts into a constant and keep only the dynamic (`isMobile`-dependent) values inline.","suggestion_code":null,"existing_code":" <div\n style={{\n marginBottom: 16,\n display: 'flex',\n justifyContent: 'space-between',\n alignItems: isMobile ? 'flex-start' : 'center',\n flexDirection: isMobile ? 'column' : 'row',\n gap: 12,\n }}\n >"}
{"path":"apps/admin/src/pages/Expenses/ExpenseModals.tsx","start_line":27,"end_line":31,"category":"bug","severity":"medium","content":"Unstable dependency in useEffect: `useDirtyGuard` returns a new object literal on every render, so `roomExpenseGuard` changes identity on each render. Any re-render of this component while the modal is open (e.g. parent state changes like `saving` toggling, list refresh, another modal) re-runs this effect and calls `snapshot()` again, resetting the \"unmodified\" baseline to the *currently edited* values. This silently defeats the dirty-guard, so users can lose unsaved edits without a confirmation prompt. Fix: depend on the stable function instead of the wrapping object, e.g. `const { confirmClose, snapshot } = useDirtyGuard(form)` and use `[open, snapshot]` (snapshot is useCallback-stable as long as `form` is stable). Same issue in UtilityModal and PersonalExpenseModal below.","suggestion_code":" const { confirmClose: roomExpenseConfirmClose, snapshot: roomExpenseSnapshot } = useDirtyGuard(form);\n // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准\n useEffect(() => {\n if (open) roomExpenseSnapshot();\n }, [open, roomExpenseSnapshot]);","existing_code":" const roomExpenseGuard = useDirtyGuard(form);\n // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准\n useEffect(() => {\n if (open) roomExpenseGuard.snapshot();\n }, [open, roomExpenseGuard]);"}
{"path":"apps/admin/src/pages/Expenses/ExpenseModals.tsx","start_line":83,"end_line":87,"category":"bug","severity":"medium","content":"Same unstable-dependency issue as in RoomExpenseModal: `utilityGuard` is a newly created object on each render, so the effect re-runs (and re-snapshots, resetting the pristine baseline) on any re-render while the modal is open, weakening the dirty-check. Use the stable `snapshot` function in the dependency array instead.","suggestion_code":" const { confirmClose: utilityConfirmClose, snapshot: utilitySnapshot } = useDirtyGuard(form);\n // 打开弹窗时记录当前表单值为「未修改」基准\n useEffect(() => {\n if (open) utilitySnapshot();\n }, [open, utilitySnapshot]);","existing_code":" const utilityGuard = useDirtyGuard(form);\n // 打开弹窗时记录当前表单值为「未修改」基准\n useEffect(() => {\n if (open) utilityGuard.snapshot();\n }, [open, utilityGuard]);"}
{"path":"apps/admin/src/pages/Expenses/ExpenseModals.tsx","start_line":143,"end_line":147,"category":"bug","severity":"medium","content":"Same unstable-dependency issue as in the other two modals: `personalExpenseGuard` is a fresh object every render, so the effect can re-run and re-snapshot (resetting the pristine baseline to edited values) on any re-render while the modal is open. Use the stable `snapshot` function in the dependency array instead.","suggestion_code":" const { confirmClose: personalExpenseConfirmClose, snapshot: personalExpenseSnapshot } = useDirtyGuard(form);\n // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准\n useEffect(() => {\n if (open) personalExpenseSnapshot();\n }, [open, personalExpenseSnapshot]);","existing_code":" const personalExpenseGuard = useDirtyGuard(form);\n // 父组件在打开弹窗前已完成表单回填,这里记录「未修改」基准\n useEffect(() => {\n if (open) personalExpenseGuard.snapshot();\n }, [open, personalExpenseGuard]);"}
{"path":"apps/admin/src/pages/Expenses/ExpenseModals.tsx","start_line":21,"end_line":22,"category":"maintainability","severity":"medium","content":"Props are typed as `any[]` and the map callbacks use `(r: any)`, `(student: any)`, `(s: any)` without a justifying comment, which the project rules prohibit. This also hides potential null/undefined issues (e.g. `r.id`/`student.id` access) and removes all type safety at the component boundary. Define minimal interfaces, e.g. `{ id: number | string; roomNumber?: string; building?: string; name?: string; studentNo?: string }` for Room/Student, and apply them to all three modals.","suggestion_code":" rooms: Array<{ id: number | string; roomNumber: string; building?: string }>;\n typeOptions: Array<{ value: string; label: string }>;","existing_code":" rooms: any[];\n typeOptions: Array<{ value: string; label: string }>;"}
{"path":"apps/admin/src/pages/Expenses/ExpenseModals.tsx","start_line":1,"end_line":1,"category":"maintainability","severity":"low","content":"Possible typo: `aislop-ignore-file` is likely meant to be `eslint-ignore-file`. If this is intended as a lint suppression directive, the misspelling makes it ineffective and the duplicated-block warning will still be reported. Correct the directive name (or rename it consistently if it is a custom tool).","suggestion_code":"// eslint-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化","existing_code":"// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化"}
{"path":"apps/admin/src/pages/Expenses/index.tsx","start_line":355,"end_line":357,"category":"bug","severity":"medium","content":"`utilityForm.validateFields()` is called outside the try/catch block, unlike `handleRoomExpense`/`handlePersonalExpense` which put it inside try. If form validation fails, the promise rejection is unhandled (unhandled promise rejection / console error), and `setSaving(true)` never runs while the modal stays open. Move `validateFields()` inside the try block and swallow validation errors there, consistent with the other two handlers.","suggestion_code":" const handleStudentUtility = async () => {\n setSaving(true);\n try {\n const values = await utilityForm.validateFields();","existing_code":" const handleStudentUtility = async () => {\n const values = await utilityForm.validateFields();\n setSaving(true);"}
{"path":"apps/admin/src/pages/Expenses/index.tsx","start_line":367,"end_line":370,"category":"bug","severity":"medium","content":"No null check on the API response before accessing `result.bill`. If the backend response doesn't include a `bill` field (or `result` is empty), `bill.paidAmount` throws a TypeError, which is silently swallowed by the catch block — the operation actually succeeded but the user gets no success feedback and the modal stays open. Guard with optional chaining / a default, e.g. `const bill = result?.bill;` and only build the amount message when `bill` exists.","suggestion_code":" const bill = result?.bill ?? {};\n message.success(\n `账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`,\n );","existing_code":" const bill = result.bill;\n message.success(\n `账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`,\n );"}
{"path":"apps/admin/src/pages/Expenses/index.tsx","start_line":280,"end_line":281,"category":"performance","severity":"medium","content":"Batch purge fires N parallel per-item permanent-delete requests (one per selected id), each of which invalidates the `['expenses']` query on success — causing N refetches — and the whole operation is non-atomic: if one request fails mid-way, some records are already permanently deleted while the catch block only shows a generic error. The codebase already has `batch-delete`/`batch-restore` endpoints; add a `batch-purge` endpoint (or at least aggregate per-item results) so this is a single atomic request with a single invalidation. Same issue in `handleBatchPurgePersonal`.","suggestion_code":null,"existing_code":" await Promise.all(selectedRoomKeys.map((id) => mutations.purgeRoom.mutateAsync(id)));\n message.success('批量永久删除成功');"}
{"path":"apps/admin/src/pages/Expenses/index.tsx","start_line":31,"end_line":32,"category":"maintainability","severity":"low","content":"Widespread use of `any` (editingRoom/editingPersonal state, query result arrays `rooms/personal/students/roomsList`, `record` params in `saveRoomCell`/`savePersonalCell`, mutation payloads) with no justification comments, per the project rule that `any` should be avoided or explained. Define proper types (e.g. an `ExpenseRecord`/`RoomExpense` interface from the schemas already imported) to catch field typos like `bill.paidAmount` at compile time.","suggestion_code":null,"existing_code":" const [editingRoom, setEditingRoom] = useState<any>(null);\n const [editingPersonal, setEditingPersonal] = useState<any>(null);"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":110,"end_line":119,"category":"performance","severity":"high","content":"`EditableExpenseCell` is declared inside the `ExpenseTablePanel` render body, so every parent render creates a brand-new component type. React then treats all editable cells as different component types and unmounts/remounts them on each render (e.g. when `loading`, `selectedKeys`, `data`, or filters change), which resets EditableCell's internal editing state (losing focus / unsaved input) and wastes reconciliation work across the whole table. Hoist this component to module scope and pass `permission`/`disabled` as props, or render cells via a plain function instead of JSX components.","suggestion_code":null,"existing_code":" const EditableExpenseCell = ({\n value,\n editor,\n min,\n max,\n required,\n options,\n onSave,\n children,\n }: {"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":232,"end_line":234,"category":"bug","severity":"medium","content":"`amount` comes from `data: any[]` (API data). If a record has `amount` as `null`/`undefined` or serialized as a string (common for money fields), `v.toFixed(2)` throws a TypeError and crashes the whole table render. Guard the value, e.g. `{`¥${Number(v ?? 0).toFixed(2)}`}` (same pattern in the personal-expense column).","suggestion_code":null,"existing_code":" >\n {`¥${v.toFixed(2)}`}\n </EditableExpenseCell>"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":271,"end_line":272,"category":"bug","severity":"low","content":"`dayjs(null)` does not throw — it returns the current date/time — so a missing `createdAt` would silently display today's timestamp. Make the formatting null-safe, e.g. `v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'`.","suggestion_code":null,"existing_code":" dataIndex: 'createdAt',\n render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":46,"end_line":49,"category":"maintainability","severity":"low","content":"The props and handlers use `any` extensively (`data`, `rooms`, `students`, `record`, `res`, etc.), which defeats type checking and directly enabled the null/format crashes above. Define typed interfaces for the expense record / room / student / import response (or make the component generic) instead of `any[]` / `any` without justification.","suggestion_code":null,"existing_code":" data: any[];\n loading: boolean;\n selectedKeys: number[];\n onSelect: (keys: number[]) => void;"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":197,"end_line":197,"category":"performance","severity":"low","content":"`rooms.map(...)` / `students.map(...)` are rebuilt for every cell on every render (O(rows × options)). Compute the select options once (e.g. `const roomOptions = useMemo(...)` ) and reuse the same array so cell re-renders stay cheap.","suggestion_code":null,"existing_code":" options={rooms.map((item) => ({ value: item.id, label: item.roomNumber }))}"}
{"path":"apps/admin/src/pages/Expenses/ExpenseTablePanel.tsx","start_line":152,"end_line":154,"category":"bug","severity":"low","content":"Single-row permanent delete calls `onPurge(record.id)` immediately without any confirmation, while the batch \"永久删除\" path wraps the same irreversible action in a `Popconfirm` with a warning. Add a `Popconfirm` here as well to prevent accidental permanent data loss.","suggestion_code":null,"existing_code":" <Button size=\"small\" danger type=\"link\" onClick={() => onPurge(record.id)}>\n 删除\n </Button>"}
{"path":"apps/admin/src/pages/Exams/index.tsx","start_line":415,"end_line":422,"category":"maintainability","severity":"low","content":"The Card `actions` array mixes a keyed `<span>` with an unkeyed `<>` fragment. React will emit a \"Each child in a list should have a unique key prop\" warning because the fragment is an array element without a key, and the `key` props on the inner `Popconfirm`s don't apply to the outer array. Use a keyed `<Fragment key=\"...\">` (or restructure into a render helper) for the archived branch.","suggestion_code":null,"existing_code":"actions={[\n <span key=\"detail\" onClick={() => navigate(`/exams/${exam.id}`)}>\n 查看成绩\n </span>,\n exam.status === 'archived' ? (\n <>\n <Popconfirm\n key=\"restore\""}
{"path":"apps/admin/src/pages/Exams/index.tsx","start_line":428,"end_line":430,"category":"style","severity":"low","content":"Nested ternary expressions are not allowed per the review rules: the `canPurgeExam ? ... : null` branch is nested inside the outer `exam.status === 'archived' ? ... : ...` ternary. Extract the archived-branch rendering into a local variable or a render method (e.g., `renderArchivedActions(exam)`) to keep the JSX flat and readable.","suggestion_code":null,"existing_code":" {canPurgeExam ? (\n <Popconfirm\n key=\"purge\""}
{"path":"apps/admin/src/pages/Exams/index.tsx","start_line":71,"end_line":81,"category":"maintainability","severity":"medium","content":"Calling `message.error` inside the classes `queryFn` is problematic: React Query retries failed queries (default 3 times) and `useVisibleRefetch(['exams'])` re-runs this query on every page re-visit, so a failing backend produces repeated toast spam. It also swallows the error by returning `[]`, so the page never surfaces the failure (no retry UI for the class filter). Prefer letting the query reject and handling the error at the query level (e.g., `useQuery` `onError` or a `QueryErrorState` for the filter), or at minimum gate the toast.","suggestion_code":null,"existing_code":" try {\n return (\n validateResponse<ClassOption[]>(\n classOptionsSchema,\n await api.get<ClassOption[]>('/classes'),\n ) ?? []\n );\n } catch (error: unknown) {\n message.error(getErrorMessage(error, '加载班级失败'));\n return [];\n }"}
{"path":"apps/admin/src/pages/Exams/index.tsx","start_line":206,"end_line":209,"category":"maintainability","severity":"low","content":"`batchPurge` and `batchChangeArchiveStatus` duplicate the same skeleton (empty/busy guard → `setBatchLoading(true)` → mutate → toast → clear selection → `finally` reset). Extract a shared helper such as `runBatchOperation(operation, successMessage)` to avoid the duplicated try/catch/finally and keep behavior consistent.","suggestion_code":null,"existing_code":" const batchPurge = async () => {\n if (selectedExamIds.length === 0 || batchLoading) return;\n setBatchLoading(true);\n try {"}
{"path":"apps/admin/src/pages/Exams/index.tsx","start_line":172,"end_line":176,"category":"bug","severity":"low","content":"The empty `catch` in `submit` silently swallows *all* errors, including genuine runtime/programming errors (e.g., if `values.examDate` is cleared by the user, `values.examDate.format(...)` throws and the user gets no feedback — the modal just stays open). Only the form-validation rejection should be ignored; re-throw or surface anything else so it isn't hidden.","suggestion_code":null,"existing_code":" } catch {\n // 校验错误静默,接口错误由 useApiMutation 统一提示\n } finally {\n setSaving(false);\n }"}
{"path":"apps/admin/src/pages/Login/index.tsx","start_line":30,"end_line":30,"category":"maintainability","severity":"medium","content":"The `values` parameter is typed as `any`, which bypasses compile-time checking of the form fields. Define a typed interface (e.g. `{ username: string; password: string }`) so field names are validated and typos are caught at build time.","suggestion_code":"async (values: { username: string; password: string }) => {","existing_code":"async (values: any) => {"}
{"path":"apps/admin/src/pages/Login/index.tsx","start_line":34,"end_line":34,"category":"maintainability","severity":"medium","content":"`res` is typed as `any` even though `api.post` is generic (`post<T>`, see `apps/admin/src/api/index.ts`). Type the login response (the `UserInfo` interface already exists in `store/user/userTypes`) so `res.access_token`/`res.user` access is statically checked instead of silently becoming `undefined` at runtime when the backend shape changes.","suggestion_code":"const res = await api.post<{ access_token: string; user: UserInfo }>('/auth/login', values);","existing_code":"const res: any = await api.post('/auth/login', values);"}
{"path":"apps/admin/src/pages/Login/index.tsx","start_line":34,"end_line":34,"category":"maintainability","severity":"low","content":"The business URL path `/auth/login` is hardcoded here and duplicated as a string literal in the interceptor of `apps/admin/src/api/index.ts` (used to detect 401 on the login request). A rename would need to be applied in two places. Extract a shared constant (e.g. `API_ENDPOINTS.login`) and reference it in both files.","suggestion_code":null,"existing_code":"const res: any = await api.post('/auth/login', values);"}
{"path":"apps/admin/src/pages/Login/index.tsx","start_line":53,"end_line":55,"category":"style","severity":"low","content":"These are static styles applied via inline `style` attributes (the wrapper div, Card, Title, Button, etc.), which the review rules disallow except for dynamic styles. Move them into a CSS module or styled component for maintainability.","suggestion_code":null,"existing_code":" style={{\n minHeight: '100vh',\n display: 'flex',"}
{"path":"apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx","start_line":255,"end_line":259,"category":"bug","severity":"medium","content":"`setImporting(true)` is missing at the start of `handleCreateClass`, so the create-class Modal's `confirmLoading={importing}` never shows a loading state, the drawer buttons stay enabled during creation (allowing double submission), and the `finally { setImporting(false) }` is effectively a no-op. Add `setImporting(true)` before `validateFields()`.","suggestion_code":" const handleCreateClass = async () => {\n setImporting(true);\n try {\n const values = await classForm.validateFields();\n const users = extractCheckedUsers();\n await api.post('/classes', { ...values, users });","existing_code":" const handleCreateClass = async () => {\n try {\n const values = await classForm.validateFields();\n const users = extractCheckedUsers();\n await api.post('/classes', { ...values, users });"}
{"path":"apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx","start_line":275,"end_line":277,"category":"bug","severity":"medium","content":"`response.data` is used without verifying `response.success` or null-guarding. If the backend returns `success: false` with a 200 (a pattern the other API calls in this file check for), `attendanceGroups` becomes `undefined`, and the confirmation Modal's `attendanceGroups.length` (in the Alert title and the OK button `disabled`) will throw a TypeError.","suggestion_code":" const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');\n if (!response.success) {\n message.error('获取钉钉考勤组失败');\n return;\n }\n setAttendanceGroups(response.data ?? []);\n setDeleteGroupsOpen(true);","existing_code":" const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');\n setAttendanceGroups(response.data);\n setDeleteGroupsOpen(true);"}
{"path":"apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx","start_line":288,"end_line":291,"category":"bug","severity":"medium","content":"`response.data.failed` / `response.data.deleted` are accessed without checking `response.success` or guarding `response.data`. If the backend returns `success: false` or an empty payload, this throws inside the try block instead of showing a user-friendly error, and the modal is never closed.","suggestion_code":" const response = await api.post<DeleteAttendanceGroupsResponse>(\n '/sync/dingtalk/attendance-groups/delete-all',\n );\n if (!response.success || !response.data) {\n message.error('清空钉钉考勤组失败');\n return;\n }\n setDeleteGroupsOpen(false);","existing_code":" const response = await api.post<DeleteAttendanceGroupsResponse>(\n '/sync/dingtalk/attendance-groups/delete-all',\n );\n setDeleteGroupsOpen(false);"}
{"path":"apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx","start_line":145,"end_line":148,"category":"maintainability","severity":"low","content":"The empty `catch` silently swallows class-list loading errors. If `/classes` fails, the drawer shows an empty class list and the user cannot distinguish \"no classes exist\" from \"load failed\". Log the error and surface a user-friendly message.","suggestion_code":" } catch (e: unknown) {\n console.error('获取班级列表失败', e);\n message.error('获取班级列表失败');\n }\n };","existing_code":" } catch {\n /* ignore */\n }\n };"}
{"path":"apps/admin/src/pages/IntegrationConfig/IntegrationOrgSyncPanel.tsx","start_line":217,"end_line":221,"category":"performance","severity":"low","content":"`checkedKeys.includes(...)` inside the nested tree walk is O(totalUsers × checkedKeys) and runs on every import. For large org trees (thousands of users) this is wasteful. Build a `Set` once before the walk and use `has()` instead.","suggestion_code":" const checkedSet = new Set(checkedKeys.map(String));\n for (const node of nodes) {\n for (const u of node.users ?? []) {\n if (checkedSet.has(`user-${u.userid}`)) {\n result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });\n }\n }","existing_code":" for (const u of node.users ?? []) {\n if (checkedKeys.includes(`user-${u.userid}`)) {\n result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });\n }\n }"}
{"path":"apps/admin/src/pages/Exams/detail.tsx","start_line":59,"end_line":63,"category":"bug","severity":"low","content":"`id` from `useParams()` is typed `string | undefined` but is used without a null check in both the detail query (`/exams/${id}`) and `saveScoreMutation` (`/exams/${id}/scores/${rowId}`). If this page is ever rendered without a valid route param (e.g. malformed URL or direct render in tests), the request will be sent to `/exams/undefined`. Consider adding `enabled: !!id` to the query and an early empty/redirect state when `id` is missing.","suggestion_code":" const { data: detail, isLoading, isFetching, isError, refetch } = useQuery<ExamDetail | null>({\n queryKey: ['exams', 'detail', id],\n enabled: !!id,\n queryFn: async () =>\n validateResponse<ExamDetail>(examDetailSchema, await api.get<ExamDetail>(`/exams/${id}`)),\n });","existing_code":" const { data: detail, isLoading, isFetching, isError, refetch } = useQuery<ExamDetail | null>({\n queryKey: ['exams', 'detail', id],\n queryFn: async () =>\n validateResponse<ExamDetail>(examDetailSchema, await api.get<ExamDetail>(`/exams/${id}`)),\n });"}
{"path":"apps/admin/src/pages/Exams/detail.tsx","start_line":111,"end_line":111,"category":"security","severity":"medium","content":"The score editing cell is gated with `permission=\"exam:view\"`, but the route `exams/:id` is already wrapped in `<PermissionRoute permission=\"exam:view\">`, so every user who can reach this page satisfies the check — meaning any viewer can silently edit scores (the gate is a no-op). If score entry is meant to require a distinct write permission (e.g. `exam:score:update`), this should use that permission; otherwise the redundant check is misleading and should be removed/commented.","suggestion_code":null,"existing_code":" permission=\"exam:view\""}
{"path":"apps/admin/src/pages/IntegrationConfig/index.tsx","start_line":126,"end_line":130,"category":"bug","severity":"medium","content":"When the test endpoint returns HTTP 200 with `success: false` (a normal failure response, which the code itself accounts for when setting `verified: res.success`), the failure message is still shown via `message.success`. This displays a success toast for a failed connection and can mislead users. Branch on `res.success` and use `message.error` for failures.","suggestion_code":" if (res.success) {\n message.success(res.message);\n } else {\n message.error(res.message);\n }","existing_code":" queryClient.setQueryData(['integration', 'config'], (prev) => ({\n ...(prev ?? { config: initialCache.config, verified: initialCache.verified }),\n verified: res.success,\n }));\n message.success(res.message);"}
{"path":"apps/admin/src/pages/IntegrationConfig/index.tsx","start_line":71,"end_line":74,"category":"bug","severity":"medium","content":"The queryFn catch swallows ALL errors and falls back to `initialCache` values captured at mount. If a background refetch (e.g., on window focus, or the refetch triggered by `saveMutation`'s invalidate) fails after a config was already loaded, this replaces the previously fetched server config with stale mount-time data — the UI can regress to an old config right after a successful save, with no user feedback. Only fall back to cache on first load (or rethrow so react-query keeps the previous data / retries).","suggestion_code":null,"existing_code":" } catch {\n // not configured\n return { config: initialCache.config, verified: initialCache.verified };\n }"}
{"path":"apps/admin/src/pages/IntegrationConfig/index.tsx","start_line":42,"end_line":45,"category":"maintainability","severity":"low","content":"`dirty` is destructured from the store but never used anywhere in the component (dead code). Also, `useMemo(..., [])` here is only a mount-time snapshot of the zustand store; it is an anti-pattern for capturing initial state and triggers exhaustive-deps lint warnings. A simpler alternative is reading the store once via `useRef`/`useState` initializer, or just calling `useIntegrationConfigStore.getState()` directly at the top of the component (it is already stable at mount).","suggestion_code":null,"existing_code":" const initialCache = useMemo(() => {\n const { loaded, config, verified, formValues, dirty } = useIntegrationConfigStore.getState();\n return { loaded, config, verified, formValues, dirty };\n }, []);"}
{"path":"apps/admin/src/pages/IntegrationConfig/index.tsx","start_line":187,"end_line":187,"category":"bug","severity":"low","content":"The `required` rule passes for whitespace-only input (e.g. `' '`), and `buildDingTalkConfigPayload` only trims without re-checking, so an empty `corpId`/`agentId` string can be submitted to `/integration/config`. Add a `whitespace: true` rule (or validate the trimmed value) for `corpId` and `agentId` so blank/space-only values are rejected before hitting the API.","suggestion_code":null,"existing_code":" rules={[{ required: true, message: '请输入 CorpId' }]}"}
{"path":"apps/admin/src/pages/Occupancies/OccupanciesTableArea.tsx","start_line":49,"end_line":49,"category":"style","severity":"low","content":"The `batchAction` branching is implemented as a chained/nested ternary (checkout ? ... : archive ? ... : ...), which is prohibited by the project's review rules. Extract the batch-action bar into a helper function or a switch/map-based lookup (e.g., `renderBatchActions()`) to make the logic more readable and maintainable.","suggestion_code":null,"existing_code":"{batchAction === 'checkout' ? ("}
{"path":"apps/admin/src/pages/Occupancies/OccupanciesTableArea.tsx","start_line":8,"end_line":9,"category":"maintainability","severity":"medium","content":"Props `columns`, `data` and `rowSelection` are typed as `any[]`/`any` without any explanatory comment. Per the project's rules, avoid `any`; define proper types (e.g., an `Occupancy` record interface, `ColumnsType<Occupancy>` for columns, and `TableRowSelection<Occupancy>` for rowSelection) or add a comment explaining why `any` is necessary here.","suggestion_code":null,"existing_code":" columns: any[];\n data: any[];"}
{"path":"apps/admin/src/pages/Occupancies/OccupanciesTableArea.tsx","start_line":127,"end_line":127,"category":"style","severity":"low","content":"Static inline styles are used repeatedly for layout spacing (`marginLeft: 12/8`, `marginBottom: 12`). Per the project's rules, avoid inline styles except for dynamic values; move these static spacings into a shared className/CSS module so spacing stays consistent and maintainable.","suggestion_code":null,"existing_code":"style={{ marginBottom: 12 }}"}
{"path":"apps/admin/src/pages/Occupancies/OccupancyColumns.tsx","start_line":87,"end_line":87,"category":"maintainability","severity":"medium","content":"Nested ternary expression (`readonly ? ... : !record.checkOutDate ? ... : ...`) is used in the action column render. This violates the project rule against nested ternaries and hurts readability. Refactor into a function body with early returns, e.g. `if (readonly) { return (...); } if (!record.checkOutDate) { return (...); } return (...);`.","suggestion_code":null,"existing_code":" ) : !record.checkOutDate ? ("}
{"path":"apps/admin/src/pages/Occupancies/OccupancyColumns.tsx","start_line":72,"end_line":72,"category":"maintainability","severity":"low","content":"The `any` type is used for the `_` parameter (and also for the `v` value parameters in the data column renders). Per the TypeScript rules, `any` should be avoided; `unknown` or a concrete type should be used. `_` can be `unknown`, and cell values can be typed as e.g. `v?: string`.","suggestion_code":null,"existing_code":" render: (_: any, record: OccupancyRow) =>"}
{"path":"apps/admin/src/pages/Occupancies/OccupancyColumns.tsx","start_line":63,"end_line":63,"category":"maintainability","severity":"low","content":"`checkOutReason` is used as a `dataIndex` here but is not declared in the `OccupancyRow` interface, so this field is untyped and the record object is inconsistent with the interface. Add `checkOutReason?: string` to `OccupancyRow`.","suggestion_code":null,"existing_code":" { title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' },"}
{"path":"apps/admin/src/pages/Occupancies/OccupancyColumns.tsx","start_line":116,"end_line":116,"category":"maintainability","severity":"low","content":"Empty `catch` block silently swallows errors from `onArchive`. Even if `useApiMutation` normally surfaces errors, a caller can pass a plain rejecting function and the user would get no feedback. Add a fallback `message.error` (or at least log the error) so failures are not completely silent.","suggestion_code":null,"existing_code":" } catch {"}
{"path":"apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx","start_line":113,"end_line":113,"category":"bug","severity":"medium","content":"Bug: `v || 500` treats `0` as falsy, so when a user sets the deposit amount to 0 (which `min={0}` explicitly allows), the value is silently coerced back to 500. As written, a 0 deposit can never be submitted. Use a nullish check instead: `v ?? 500` preserves 0 while still falling back to the default when the input is cleared (null).","suggestion_code":" onChange={(v) => onDepositAmountChange(v ?? 500)}","existing_code":" onChange={(v) => onDepositAmountChange(v || 500)}"}
{"path":"apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx","start_line":23,"end_line":23,"category":"maintainability","severity":"medium","content":"Avoid the `any` type for the import callback. Since it is passed directly as Upload's `customRequest`, it should be typed as antd's `UploadProps['customRequest']` (or `UploadRequestOption<File>` from rc-upload) instead of `(options: any) => void`. This gives type safety on `options.file`/`onSuccess`/`onError` and matches the parent's implementation, which also destructures these fields.","suggestion_code":" onImport: UploadProps['customRequest'];","existing_code":" onImport: (options: any) => void;"}
{"path":"apps/admin/src/pages/Occupancies/OccupanciesToolbar.tsx","start_line":116,"end_line":117,"category":"style","severity":"low","content":"Per the project's inline-style rule, static styles should live in CSS classes rather than inline `style` attributes. Here multiple static styles are used (widths on Input.Search/RangePicker/InputNumber, and the '元' suffix span), which makes the component harder to maintain and re-theme. Only the conditionally-applied styles would justify inline usage.","suggestion_code":null,"existing_code":" <span\n style={{"}
{"path":"apps/admin/src/pages/OperationLogs/index.tsx","start_line":42,"end_line":42,"category":"maintainability","severity":"medium","content":"Avoid the `any` type (project rule: TypeScript types). `params: any` and the `{ data: any[]; total: number }` generic bypass all type checking, so a typo in a param name (e.g. `pageSize` vs `page_size`) or a mismatch in the response shape would go undetected. Define a typed interface for the query params and for the log item (id, createdAt, username, module, action, status, detail, ipAddress, userAgent) and use it consistently here and in the query generic.","suggestion_code":" const params: OperationLogsParams = { page, pageSize };","existing_code":" const params: any = { page, pageSize };"}
{"path":"apps/admin/src/pages/OperationLogs/index.tsx","start_line":79,"end_line":79,"category":"bug","severity":"medium","content":"Falling back to `statusMap['success']` means any unexpected or missing status value renders as a green \"成功\" tag. For an audit log this is misleading — a failed or unknown record would appear successful. Use a neutral default instead, e.g. `const s = statusMap[v] ?? { text: '未知', color: 'default' };`.","suggestion_code":" const s = statusMap[v] ?? { text: '未知', color: 'default' };","existing_code":" const s = statusMap[v] || statusMap['success'];"}
{"path":"apps/admin/src/pages/OperationLogs/index.tsx","start_line":143,"end_line":143,"category":"maintainability","severity":"low","content":"The module list is duplicated: it is hardcoded here and again as the keys of `moduleColorMap`. If a module is added or removed, the two lists can drift (filter shows options that have no tag color and vice versa). Derive the options from the single source of truth: `Object.keys(moduleColorMap).map((m) => ({ value: m, label: m }))`.","suggestion_code":" options={Object.keys(moduleColorMap).map((m) => ({","existing_code":" options={['认证', '学生', '宿舍', '入住', '费用', '账单', '账号'].map((m) => ({"}
{"path":"apps/admin/src/pages/OperationLogs/index.tsx","start_line":122,"end_line":124,"category":"style","severity":"low","content":"This is a static inline style block; per the project's React best practices, inline `style` should only be used for dynamic styles. Move the layout styles to a CSS class (or styled component) for consistency with the rest of the codebase.","suggestion_code":null,"existing_code":" <div\n style={{\n marginBottom: 16,"}
{"path":"apps/admin/src/pages/Notifications/index.tsx","start_line":165,"end_line":167,"category":"bug","severity":"medium","content":"When a \"load more\" request fails, `setError(true)` is called while `loading` is already false, so the condition `error && !loading` becomes true and the entire list of already-loaded notifications is replaced by the error state. The retry then calls `loadPage()` with no cursor, discarding previously loaded pages and scroll position. Restrict the full error state to initial-load failures and keep the list visible for load-more failures (e.g. show a toast instead).","suggestion_code":" {error && notifications.length === 0 && !loading ? (\n <QueryErrorState\n title=\"通知加载失败\"","existing_code":" {error && !loading ? (\n <QueryErrorState\n title=\"通知加载失败\""}
{"path":"apps/admin/src/pages/Notifications/index.tsx","start_line":90,"end_line":91,"category":"maintainability","severity":"low","content":"`catch (e: any)` uses the `any` type without a justification comment, which violates the project rule that `any` should be avoided (or explained). The same pattern is repeated in `handleClick` and `handleMarkAll`. Prefer `catch (e)` and narrow the type (e.g. `e instanceof Error ? e.message : ...`) or treat the error as `unknown` and extract the message safely.","suggestion_code":null,"existing_code":" } catch (e: any) {\n console.error('加载通知失败', e);"}
{"path":"apps/admin/src/pages/Notifications/index.tsx","start_line":86,"end_line":86,"category":"maintainability","severity":"low","content":"The API path `/notifications` is hardcoded in three places (`loadPage`, `handleClick`, `handleMarkAll`), plus `/notifications/read-all` and `/notifications/${id}/read`. Per the project rule against hardcoded business URL paths, extract a shared constant (e.g. `const NOTIFICATIONS_API = '/notifications';`) so the endpoints stay consistent if the prefix changes.","suggestion_code":" await api.get(`${NOTIFICATIONS_API}${params}`),","existing_code":" await api.get(`/notifications${params}`),"}
{"path":"apps/admin/src/pages/Notifications/index.tsx","start_line":139,"end_line":139,"category":"style","severity":"low","content":"Several static inline styles are used on this element and throughout the component (Sider width/border, Content padding, header margins, avatar circle, list padding, etc.). Per the checklist, inline styles should be reserved for dynamic values (e.g. `backgroundColor` based on `isRead`); static styling should live in CSS classes/theme tokens for maintainability.","suggestion_code":null,"existing_code":" <Layout className=\"notifications-layout\" style={{ minHeight: '100%', background: '#fff' }}>"}
{"path":"apps/admin/src/pages/Organizations/index.tsx","start_line":182,"end_line":182,"category":"performance","severity":"medium","content":"`EditableOrganizationCell` is declared inside `OrganizationsPage`, so it is a brand-new component type on every render. Any re-render of the page (typing in the search box, `isFetching` toggling `loading`, `saving` state changes, etc.) will unmount/remount the entire editable-cell subtree, which can lose focus/IME state during an in-progress inline edit and defeats any cell-level memoization. Hoist this component to module scope (pass `onSave`, `disabled`, `permission` as props) and wrap the `columns` array in `useMemo` so the component identity is stable across renders.","suggestion_code":null,"existing_code":" const EditableOrganizationCell = <R extends { id: number; status?: string }>({"}
{"path":"apps/admin/src/pages/Organizations/index.tsx","start_line":300,"end_line":302,"category":"maintainability","severity":"low","content":"The 操作 column render contains nested conditional expressions: the `archived` branch further nests `canPurgeOrganization && !record.isHost ? ... : null`, and the `active` branch nests `!record.isHost ? ... : null`. Per the project rules, nested ternaries are not allowed. Extract the per-status action rendering into a helper (e.g., `renderActions(record)` using early returns) to keep the JSX flat and readable.","suggestion_code":null,"existing_code":" render: (_: unknown, record: OrganizationItem) => (\n <Space>\n {record.status === 'archived' ? ("}
{"path":"apps/admin/src/pages/Organizations/index.tsx","start_line":376,"end_line":384,"category":"style","severity":"low","content":"Several static layout styles are inlined (toolbar flex/gap layout, Alert `marginBottom`, search/select widths), while only dynamic values truly need inline style (the color dot background and the color swatches). Per the review rules, inline styles should be reserved for dynamic values; consider moving these static layout styles into CSS classes/modules.","suggestion_code":null,"existing_code":" <div\n style={{\n marginBottom: 16,\n display: 'flex',\n justifyContent: 'space-between',\n flexWrap: 'wrap',\n gap: 8,\n }}\n >"}
{"path":"apps/admin/src/pages/Occupancies/OccupancyModals.tsx","start_line":82,"end_line":84,"category":"bug","severity":"medium","content":"useDirtyGuard returns a freshly created object literal ({ confirmClose, snapshot, isDirty }) on every render, so `checkInGuard` in this dependency array is a new reference after each render. This means the effect re-runs on every render of the modal (not just on open), re-recording whatever the user has already typed as the 'pristine' baseline. If the parent re-renders mid-edit (e.g. available resources finish loading, `saving`/`canCheckIn` toggles), the dirty-close guard is silently defeated and the user's unsaved changes can be discarded without confirmation. Depend on the stable callback instead: `[open, canCheckIn, checkInGuard.snapshot]` (snapshot is a useCallback keyed on `form`). The same unstable-object pattern is repeated in CheckOutModal, BatchCheckOutModal and TransferModal.","suggestion_code":" useEffect(() => {\n if (open && canCheckIn) checkInGuard.snapshot();\n }, [open, canCheckIn, checkInGuard.snapshot]);","existing_code":" useEffect(() => {\n if (open && canCheckIn) checkInGuard.snapshot();\n }, [open, canCheckIn, checkInGuard]);"}
{"path":"apps/admin/src/pages/Occupancies/OccupancyModals.tsx","start_line":106,"end_line":110,"category":"style","severity":"low","content":"Nested ternary expression (prohibited by review rules). The identifier fallback chains three branches inline, which is hard to read. Since this arrow function already uses a block body, replace it with flat if/else statements.","suggestion_code":" let identifier = '';\n if (s.idNumber) {\n identifier = maskIdNumber(s.idNumber);\n } else if (s.phone) {\n identifier = maskPhone(s.phone);\n }","existing_code":" const identifier = s.idNumber\n ? maskIdNumber(s.idNumber)\n : s.phone\n ? maskPhone(s.phone)\n : '';"}
{"path":"apps/admin/src/pages/Occupancies/OccupancyModals.tsx","start_line":58,"end_line":58,"category":"maintainability","severity":"low","content":"`dateNotBefore` is declared to return `unknown`, which forces every call site to smuggle the validator through `as never` casts inside rule objects (e.g. `{ validator: dateNotBefore(...) as never }`). The actual implementation returns a validator function `(_: unknown, value?: Dayjs | null) => Promise<void>`. Typing the prop as returning that validator function would let callers drop all the `as never` casts and keep type checking meaningful.","suggestion_code":" dateNotBefore: (\n start: string | Dayjs | null | undefined,\n messageText: string,\n ) => (rule: unknown, value?: Dayjs | null) => Promise<void>;","existing_code":" dateNotBefore: (start: string | Dayjs | null | undefined, messageText: string) => unknown;"}
{"path":"apps/admin/src/pages/Occupancies/OccupancyModals.tsx","start_line":48,"end_line":50,"category":"maintainability","severity":"low","content":"Several props are loosely typed as `any[]` (`students`, `rooms`, `availableBeds`, `availableLockers` here, and `data` in BatchCheckOutModal), losing all compile-time safety in a file that already imports a typed `OccupancyRow`. Define proper interfaces (e.g. `Student`, `Room`, `Bed`, `Locker`) — the parent page already has these shapes — instead of `any[]` (review rule: avoid `any` unless justified with a comment).","suggestion_code":" students: Student[];\n activeOccupancyByStudentId: Map<number, OccupancyRow>;\n rooms: Room[];","existing_code":" students: any[];\n activeOccupancyByStudentId: Map<number, OccupancyRow>;\n rooms: any[];"}
{"path":"apps/admin/src/pages/Occupancies/OccupancyModals.tsx","start_line":361,"end_line":364,"category":"bug","severity":"low","content":"Inconsistency with the single CheckOutModal: this validator only compares `billingEndDate` against `latestSelectedBillingStartDate` and has no dependency on the chosen `checkOutDate`, so a billing end date earlier than the checkout date passes validation (in the single-checkout modal the rule falls back to `getFieldValue('checkOutDate')`). Add `dependencies={['checkOutDate']}` and include the checkout date in the fallback chain.","suggestion_code":" dependencies={['checkOutDate']}\n rules={[\n {\n validator: dateNotBefore(\n latestSelectedBillingStartDate || latestSelectedCheckInDate || getFieldValue('checkOutDate'),\n '计费截止日不能早于所选记录中最晚的计费起始日',\n ) as never,\n },\n ]}","existing_code":" validator: dateNotBefore(\n latestSelectedBillingStartDate,\n '计费截止日不能早于所选记录中最晚的计费起始日',\n ) as never,"}
{"path":"apps/admin/src/pages/Occupancies/index.tsx","start_line":528,"end_line":530,"category":"bug","severity":"medium","content":"The export params only distinguish 'active' from everything else: for the 'archived' view it sends no query params, so the exported file will not match the currently displayed archived records (the list query uses `status: 'archived'` for that view, see `occupancyParamsForView`). The 'all' view also exports the same URL as 'archived'. Derive the export URL from `viewMode` (or from `occupancyParamsForView`) so each view exports its own data.","suggestion_code":" onExport={() => {\n const params =\n viewMode === 'active'\n ? '?active=true'\n : viewMode === 'archived'\n ? '?status=archived'\n : '';\n const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx';","existing_code":" onExport={() => {\n const params = viewMode === 'active' ? '?active=true' : '';\n const filename = viewMode === 'active' ? '在住记录.xlsx' : '全部入住记录.xlsx';"}
{"path":"apps/admin/src/pages/Occupancies/index.tsx","start_line":288,"end_line":293,"category":"bug","severity":"medium","content":"`checkOutModal`/`transferModal` are typed `any` and initialized to `null`, but `checkOutModal.id` / `transferModal.id` are dereferenced here without any null guard. If `onOk` ever fires after the modal state has been reset (or is opened with a stale record), this throws a TypeError. Type the states as `OccupancyRow | null` and guard before use (the same applies to `handleTransfer` and `transferModal.id`).","suggestion_code":" const handleCheckOut = async () => {\n if (!checkOutModal) return;\n const values = await checkOutForm.validateFields();\n setSaving(true);\n try {\n await checkOutMutation.mutateAsync({\n id: checkOutModal.id,","existing_code":" const handleCheckOut = async () => {\n const values = await checkOutForm.validateFields();\n setSaving(true);\n try {\n await checkOutMutation.mutateAsync({\n id: checkOutModal.id,"}
{"path":"apps/admin/src/pages/Occupancies/index.tsx","start_line":271,"end_line":273,"category":"bug","severity":"medium","content":"`checkInForm.validateFields()` is awaited before the try/catch block, so a validation failure rejects the promise returned to the Modal's `onOk` with no handling here (field errors are shown by the form, but the rejection is unhandled and `saving`/modal state is never managed). The same pattern exists in `handleCheckOut`, `handleTransfer`, and `handleBatchCheckOut`. Wrap `validateFields()` in its own try/catch and return early on validation failure.","suggestion_code":" const handleCheckIn = async () => {\n let values;\n try {\n values = await checkInForm.validateFields();\n } catch {\n return;\n }\n setSaving(true);","existing_code":" const handleCheckIn = async () => {\n const values = await checkInForm.validateFields();\n setSaving(true);"}
{"path":"apps/admin/src/pages/Occupancies/index.tsx","start_line":422,"end_line":422,"category":"bug","severity":"low","content":"`archiveMutation.mutateAsync(id)` is returned directly as the `onArchive` callback. Since `useApiMutation` only shows the error toast and `mutateAsync` still rejects on failure, a failed archive produces an unhandled promise rejection. Add `.catch(() => {})` (the toast is already handled by the mutation wrapper).","suggestion_code":" onArchive: (id) => archiveMutation.mutateAsync(id).catch(() => {}),","existing_code":" onArchive: (id) => archiveMutation.mutateAsync(id),"}
{"path":"apps/admin/src/pages/Occupancies/index.tsx","start_line":143,"end_line":144,"category":"maintainability","severity":"low","content":"These resource lists (and the similar `transferAvailableBeds`/`transferAvailableLockers`) are typed as `any[]`, and `handleRoomChange`/`handleTransferRoomChange` treat them opaquely. The file already has a `OccupancyRow`/`RoomOverviewRow` shape for related data; define a concrete bed/locker type instead of `any` so field access (`beds[0].id`) is checked. Also avoid the untyped `r: any` in `filteredData` (the `data` array is already `OccupancyRow[]`).","suggestion_code":null,"existing_code":" const [availableBeds, setAvailableBeds] = useState<any[]>([]);\n const [availableLockers, setAvailableLockers] = useState<any[]>([]);"}
{"path":"apps/admin/src/pages/Occupancies/index.tsx","start_line":493,"end_line":493,"category":"maintainability","severity":"low","content":"`onImport` destructures `{ file, onSuccess, onError }` with an untyped `any` parameter and `res` is also `any`, which bypasses type checking for the import result (`res.errors`, `res.message`). Define an interface for the import props and result type.","suggestion_code":null,"existing_code":" onImport={async ({ file, onSuccess, onError }: any) => {"}
{"path":"apps/admin/src/pages/Occupancies/index.tsx","start_line":468,"end_line":470,"category":"maintainability","severity":"low","content":"Business values are hardcoded here and duplicated: `stayType: 'short'` and `depositAmount: 500` also appear as the initial `useState` defaults. Note the check-in modal always resets the deposit to 500 even if the user changed `depositAmount` in the toolbar for imports, which is inconsistent. Extract these to named constants (or reuse the `depositAmount` state) so the default stays in sync.","suggestion_code":null,"existing_code":" stayType: 'short',\n collectDeposit: true,\n depositAmount: 500,"}
{"path":"apps/admin/src/pages/RoomVisual/index.tsx","start_line":350,"end_line":355,"category":"maintainability","severity":"low","content":"Nested ternary expression (checklist: nested ternaries are not allowed). Extract the badge color into a small helper function, e.g. `getOccupancyBadgeColor(currentCount, capacity)` using if/else, and reference it here.","suggestion_code":" backgroundColor: getOccupancyBadgeColor(room.currentCount, room.capacity),","existing_code":" backgroundColor:\n room.currentCount >= room.capacity\n ? '#FF3B30'\n : room.currentCount > 0\n ? '#007AFF'\n : '#34C759',"}
{"path":"apps/admin/src/pages/RoomVisual/index.tsx","start_line":482,"end_line":488,"category":"maintainability","severity":"low","content":"Nested ternary expression (checklist: nested ternaries are not allowed). Replace with a lookup map, e.g. `const inspectionColor: Record<string, string> = { present: 'green', absent: 'red' };` and use `color={inspectionColor[o.inspectionStatus] || 'default'}`.","suggestion_code":" color={inspectionColor[o.inspectionStatus] || 'default'}","existing_code":" color={\n o.inspectionStatus === 'present'\n ? 'green'\n : o.inspectionStatus === 'absent'\n ? 'red'\n : 'default'\n }"}
{"path":"apps/admin/src/pages/RoomVisual/index.tsx","start_line":85,"end_line":85,"category":"maintainability","severity":"medium","content":"Heavy use of `any` throughout this file (useQuery<any>, useState<any>(null), room: any, o: any, res: any, data: any) defeats TypeScript type safety. Per the review checklist, `any` should be avoided (or justified with a comment). Define interfaces for Room/Occupant/VisualResponse and type the query result and state accordingly.","suggestion_code":null,"existing_code":" const { data, isLoading, isFetching, isError } = useQuery<any>({"}
{"path":"apps/admin/src/pages/RoomVisual/index.tsx","start_line":194,"end_line":194,"category":"bug","severity":"low","content":"`data.buildings` is accessed with `.map` without a null/undefined check (unlike `data.organizations` which is guarded with `|| []`). If the API response ever omits `buildings`, this will throw and crash the page. Use `(data.buildings || []).map(...)` for consistency.","suggestion_code":" ...(data.buildings || []).map((b: string) => ({ value: b, label: b })),","existing_code":" ...data.buildings.map((b: string) => ({ value: b, label: b })),"}
{"path":"apps/admin/src/pages/RoomVisual/index.tsx","start_line":374,"end_line":374,"category":"bug","severity":"low","content":"Using `o.studentId` as the React key can produce duplicate keys if the same student appears more than once among a room's occupants (e.g., historical/re-entry records), which causes unstable rendering and warnings. `o.occupancyId` is unique per occupancy and is already used as the key for the occupant cards in the detail modal — use it here too.","suggestion_code":" <Tooltip key={o.occupancyId} title={`入住 ${o.days} 天 (${o.checkInDate} 起)`}>","existing_code":" <Tooltip key={o.studentId} title={`入住 ${o.days} 天 (${o.checkInDate} 起)`}>"}
{"path":"apps/admin/src/pages/RoomVisual/index.tsx","start_line":160,"end_line":160,"category":"bug","severity":"low","content":"`totalBeds - occupiedBeds` can become negative if the API reports `occupiedBeds > totalBeds` (e.g., data inconsistency during re-assignment). Guard with `Math.max(0, ...)` so the \"可安排床位\" statistic never displays a negative number.","suggestion_code":" const availableBedsCount = Math.max(0, totalBeds - occupiedBeds);","existing_code":" const availableBedsCount = totalBeds - occupiedBeds;"}
{"path":"apps/admin/src/pages/RoomVisual/index.tsx","start_line":111,"end_line":113,"category":"bug","severity":"medium","content":"Issues in post-submit refresh: (1) Since `isHistorical` is always false here (the early return blocks historical submits), `params` is always `undefined`; if the user has picked today's date in the picker (`asOf` = today, non-historical), the refetched data was fetched without the `asOf` param but is cached under a key that includes `asOf`, a key/data mismatch. (2) If the PUT succeeds but this refetch GET fails, the catch block reports \"查寝提交失败\" even though the submission actually succeeded — a misleading error. Prefer `await queryClient.invalidateQueries({ queryKey: ['rooms', 'visual'] })` and deriving `updatedRoom` from the refreshed query data, which avoids the manual double-fetch and the stale-key problem.","suggestion_code":null,"existing_code":" const params = isHistorical ? { asOf: inspectionDate } : undefined;\n const res: any = await api.get('/rooms/visual', { params });\n queryClient.setQueryData(['rooms', 'visual', isHistorical, asOf], res);"}
{"path":"apps/admin/src/pages/Rooms/RoomModals.tsx","start_line":55,"end_line":58,"category":"bug","severity":"medium","content":"Auto-fill via `form.setFieldsValue` runs on every keystroke and unconditionally overwrites `building`, `floor` and `roomType` fields. This clobbers any manual input/correction the user made in those fields (including during edit mode) whenever the room number changes. Also, for the placeholder example `4-102`, `parseRoomNumber` returns `floor: 102` (regex takes the whole tail as the floor), so the parsed floor is incorrect. Suggest parsing on blur / only filling fields that are still empty, and verifying the parse logic for multi-digit room numbers.","suggestion_code":null,"existing_code":" onChange={(e) => {\n const parsed = parseRoomNumber(e.target.value);\n if (parsed) form.setFieldsValue(parsed);\n }}"}
{"path":"apps/admin/src/pages/Rooms/RoomModals.tsx","start_line":170,"end_line":170,"category":"maintainability","severity":"medium","content":"`drawerRoom` is typed as `any`, which bypasses type checking for all downstream usage in `RoomDrawer` and its callers. Prefer the exported `Room` type from `./RoomColumns` (or a dedicated props type); if `any` is truly unavoidable, add a comment explaining why.","suggestion_code":null,"existing_code":" drawerRoom: any;"}
{"path":"apps/admin/src/pages/Rooms/RoomModals.tsx","start_line":82,"end_line":91,"category":"maintainability","severity":"low","content":"The `status`/`rentalCategory`/`roomType` Select options are re-hardcoded here even though `RoomColumns.tsx` already exports `ROOM_STATUS_OPTIONS` and `RENTAL_CATEGORY_OPTIONS` (plus a status map). Reusing those constants avoids the option lists drifting out of sync (e.g. bed/locker status options here only list 2 of the 3 statuses defined in `BED_STATUS_OPTIONS`).","suggestion_code":null,"existing_code":" <Form.Item name=\"rentalCategory\" label=\"租赁类别\">\n <Select\n allowClear\n options={[\n { value: 'short', label: '短租' },\n { value: 'long', label: '长租' },\n ]}\n placeholder=\"默认为短租\"\n />\n </Form.Item>"}
{"path":"apps/admin/src/pages/Rooms/RoomDrawer.tsx","start_line":192,"end_line":201,"category":"maintainability","severity":"medium","content":"Reading the batch count via `document.getElementById` inside `onConfirm` is fragile: it couples the handler to antd InputNumber's DOM structure and bypasses its min/max clamping (a user can type an out-of-range number and confirm before blur clamps it, so the count is not validated against `remainingBedSlots`). If the popup content is unmounted or the id ever collides, the silent fallback `defaultBatchBedCount` generates an unintended count. Prefer a controlled state (or a ref) for the count and clamp it to `[1, remainingBedSlots]` before calling `onBatchBeds`.","suggestion_code":" onConfirm={() => {\n const count = Math.min(\n Math.max(batchBedCount, 1),\n remainingBedSlots,\n );\n onBatchBeds(count);\n }}","existing_code":" onConfirm={() => {\n const input = document.getElementById(\n 'batch-bed-count',\n ) as HTMLInputElement;\n onBatchBeds(\n input\n ? parseInt(input.value) || defaultBatchBedCount\n : defaultBatchBedCount,\n );\n }}"}
{"path":"apps/admin/src/pages/Rooms/RoomDrawer.tsx","start_line":317,"end_line":322,"category":"maintainability","severity":"medium","content":"Same issue as the bed batch flow: the locker batch count is read from the DOM (`getElementById('batch-locker-count')`) and `parseInt`'d in `onConfirm`, ignoring the InputNumber's min/max constraints and hardcoding the `max=20`/`default=4` business rules inline. Use a controlled state/ref and clamp the value, and move the hardcoded limits (20, 4) into named constants so they can be shared and documented.","suggestion_code":" onConfirm={() => {\n onBatchLockers(\n Math.min(Math.max(lockerBatchCount, 1), 20),\n );\n }}","existing_code":" onConfirm={() => {\n const input = document.getElementById(\n 'batch-locker-count',\n ) as HTMLInputElement;\n onBatchLockers(input ? parseInt(input.value) || 4 : 4);\n }}"}
{"path":"apps/admin/src/pages/Rooms/RoomDrawer.tsx","start_line":27,"end_line":27,"category":"maintainability","severity":"medium","content":"`room` is typed as `any`, which disables type checking for every field used below (`room.status`, `room.rentalCategory`, `room.capacity`, `room.monthlyRate`, ...). The same applies to `r: any` in `roomItemActions`. Define a proper `RoomInfo` type (and reuse `BedItem`/`LockerItem`) so field typos and invalid status/category comparisons are caught at compile time. Note that without a type, the status comparisons such as `room?.status === 'archived'` are unverifiable.","suggestion_code":"export interface RoomInfo {\n id: number;\n roomNumber: string;\n building?: string;\n floor?: number;\n roomType?: string;\n capacity: number;\n rentalCategory?: 'long' | 'short';\n monthlyRate?: number;\n status: string;\n}\n\n// ...\nroom: RoomInfo;","existing_code":" room: any;"}
{"path":"apps/admin/src/pages/Rooms/RoomDrawer.tsx","start_line":139,"end_line":139,"category":"bug","severity":"low","content":"The fallback `: '短租'` mislabels any non-'long' value as 'short-term', including `undefined`/missing `rentalCategory`. If the field is absent or holds an unexpected value, the UI silently shows the wrong rental category. Use a lookup map and display '-' for unknown/missing values.","suggestion_code":" {room.rentalCategory\n ? RENTAL_CATEGORY_MAP[room.rentalCategory] ?? '-'\n : '-'}","existing_code":" {room.rentalCategory === 'long' ? '长租' : '短租'}"}
{"path":"apps/admin/src/pages/Rooms/RoomDrawer.tsx","start_line":89,"end_line":90,"category":"maintainability","severity":"low","content":"Naming mismatch: the UI presents these actions as '归档' (archive) with confirm '确定归档?', but the underlying props are `onDeleteBed`/`onDeleteLocker`. If those callbacks actually remove rows from the backend, the UI is misleading and risks accidental data loss; if they archive, the prop names should be `onArchiveBed`/`onArchiveLocker`. Align naming with actual behavior.","suggestion_code":null,"existing_code":" {r.status !== 'occupied' && canEditRooms && (\n <Popconfirm title=\"确定归档?\" onConfirm={() => handleDelete(r.id)}>"}
{"path":"apps/admin/src/pages/Rooms/RoomDrawer.tsx","start_line":116,"end_line":116,"category":"style","severity":"low","content":"Static layout/spacing styles are defined inline throughout this file (e.g. this flex column container, `marginBottom: 12` on toolbar rows). Per project rules, prefer CSS modules/classNames for non-dynamic styles to keep styles maintainable and avoid re-created style objects on every render.","suggestion_code":null,"existing_code":" <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>"}
{"path":"apps/admin/src/pages/Rooms/RoomColumns.tsx","start_line":51,"end_line":59,"category":"bug","severity":"medium","content":"`parseRoomNumber` mis-parses the actual room-number format used by this module. The placeholder in RoomModals is \"如4-102\" (building-room, `X-YZZ`), and the server-side canonical parser (`apps/server/src/rooms/room-number.ts`) derives the floor from the *first digit of the room part* (`4-102` -> floor 1) and infers roomType from the building number (`2`->单人间, `8`->爆改房, `X-Y-ZZZ`->家庭房). Here `floor: Number(match[2])` yields 102 for \"4-102\", and the roomType inference based on literal text \"单人\"/\"家庭\" never matches numeric room numbers, so it always falls back to '四人间'. The result is wrong auto-prefill of the floor/roomType fields when creating a room. Also the regex is not end-anchored, so trailing garbage still matches.","suggestion_code":"export function parseRoomNumber(input: string) {\n const cleaned = input.replace(/[(].*?[)]/g, '').trim();\n // 家庭房: X-Y-ZZZ\n const familyMatch = cleaned.match(/^(\\d+)-(\\d+)-(\\d+)$/);\n if (familyMatch) {\n return {\n building: `${familyMatch[1]}-${familyMatch[2]}栋`,\n floor: Number(familyMatch[3].charAt(0)),\n roomType: '家庭房',\n };\n }\n // 标准: X-YZZ\n const stdMatch = cleaned.match(/^(\\d+)-(\\d+)$/);\n if (!stdMatch) return null;\n const floor = Number(stdMatch[2].charAt(0));\n const roomTypeByBuilding: Record<string, string> = { '2': '单人间', '8': '爆改房' };\n return {\n building: `${stdMatch[1]}号楼`,\n floor,\n roomType: roomTypeByBuilding[stdMatch[1]] ?? '四人间',\n };\n}","existing_code":"export function parseRoomNumber(input: string) {\n const match = /^(\\d+)-(\\d+)/.exec(input.trim());\n if (!match) return null;\n return {\n building: `${match[1]}号楼`,\n floor: Number(match[2]),\n roomType: input.includes('单人') ? '单人间' : input.includes('家庭') ? '家庭房' : '四人间',\n };\n}"}
{"path":"apps/admin/src/pages/Rooms/RoomColumns.tsx","start_line":57,"end_line":57,"category":"maintainability","severity":"low","content":"Nested ternary expression is prohibited by the review rules; it also makes the roomType inference harder to read/verify. Extract a small lookup map or helper as shown in the suggested fix for `parseRoomNumber` above.","suggestion_code":null,"existing_code":" roomType: input.includes('单人') ? '单人间' : input.includes('家庭') ? '家庭房' : '四人间',"}
{"path":"apps/admin/src/pages/Rooms/RoomColumns.tsx","start_line":107,"end_line":112,"category":"maintainability","severity":"medium","content":"The `RoomColumnContext` callbacks and all column `render` functions are typed with `any` (record/value), which defeats type safety across the whole Rooms module — e.g., `r.currentCount`/`r.capacity` in the 当前入住 column are accessed without any guarantee they exist. Define a shared `RoomRow` interface (id, roomNumber, building, floor, roomType, rentalCategory, monthlyRate, capacity, currentCount, status) and use it for `onSaveRoomCell`, `onView`, `onEdit` and the render callbacks.","suggestion_code":null,"existing_code":" onSaveRoomCell: (record: any, field: string, value: unknown) => Promise<void> | void;\n onRestore: (id: number) => Promise<unknown> | unknown;\n onArchive: (id: number) => Promise<unknown> | unknown;\n onPurge: (id: number, name: string) => void;\n onView: (record: any) => void;\n onEdit: (record: any) => void;"}
{"path":"apps/admin/src/pages/Rooms/RoomColumns.tsx","start_line":322,"end_line":324,"category":"performance","severity":"low","content":"`React.useMemo(..., [ctx])` never caches: the caller (`pages/Rooms/index.tsx`) passes a fresh object literal with inline arrow closures (`onView`, `onEdit`) on every render, so `ctx` is a new reference each time and `buildRoomColumns` re-creates all column objects (and their render closures) on every render. Either memoize `ctx` with stable `useCallback` handlers at the call site, or drop the `useMemo` and document that columns are cheap to rebuild.","suggestion_code":null,"existing_code":"export function useRoomColumns(ctx: RoomColumnContext) {\n return React.useMemo(() => buildRoomColumns(ctx), [ctx]);\n}"}
{"path":"apps/admin/src/pages/Permissions/index.tsx","start_line":31,"end_line":34,"category":"bug","severity":"medium","content":"Catching the error inside `queryFn` and returning `[]` prevents react-query from ever entering the error state: `isError` stays `false`, automatic retries are disabled, and after a failed load the UI renders `Empty` with \"未找到匹配的权限\", misleading the user into thinking there are simply no permissions (only a transient toast reveals the real problem). Let the query reject and render a distinct error state (e.g., using `isError`/`error` from useQuery), or at least distinguish \"load failed\" from \"no matching results\" in the Empty message.","suggestion_code":null,"existing_code":" } catch (e: unknown) {\n message.error(getErrorMessage(e, '加载权限失败'));\n return [];\n }"}
{"path":"apps/admin/src/pages/Permissions/index.tsx","start_line":82,"end_line":89,"category":"maintainability","severity":"low","content":"These static inline styles violate the project rule against inline `style` attributes (allowed only for dynamic styles). Move the toolbar/filter layout styles to a CSS module or class names so the layout stays consistent and themeable.","suggestion_code":null,"existing_code":" style={{\n marginBottom: 16,\n display: 'flex',\n justifyContent: 'space-between',\n alignItems: 'center',\n flexWrap: 'wrap',\n gap: 8,\n }}"}
{"path":"apps/admin/src/pages/Permissions/index.tsx","start_line":28,"end_line":28,"category":"maintainability","severity":"low","content":"Per the review rules, business URL paths should not be hardcoded inline. Consider centralizing this endpoint (e.g., an api-endpoint constant) so it can be reused and kept consistent with the backend contract.","suggestion_code":null,"existing_code":" '/rbac/permissions/tree',"}
{"path":"apps/admin/src/pages/Rooms/RoomsTable.tsx","start_line":6,"end_line":7,"category":"maintainability","severity":"medium","content":"`columns` and `data` are typed as `any[]`, losing all type safety on the table (column keys, row fields, and `record` in `rowClassName` are all untyped). Per the project's TS rules, avoid `any`; prefer generic typing, e.g. `TableProps<RoomItem>['columns']` and `RoomItem[]`. If `any` is unavoidable, add a comment explaining why.","suggestion_code":null,"existing_code":" columns: any[];\n data: any[];"}
{"path":"apps/admin/src/pages/Rooms/RoomsTable.tsx","start_line":40,"end_line":40,"category":"maintainability","severity":"low","content":"The status string `'archived'` is a business magic string duplicated across the codebase (also hardcoded in index.tsx's `selectArchiveRecords(..., showArchived ? 'archived' : 'active')`). Extract a shared constant (e.g. `ROOM_STATUS.ARCHIVED`) so the two places can't drift apart and typos are caught at compile time.","suggestion_code":null,"existing_code":"rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}"}
{"path":"apps/admin/src/pages/Rooms/RoomsTable.tsx","start_line":46,"end_line":46,"category":"maintainability","severity":"low","content":"Rendering a `<style>` tag from inside the component injects a new global stylesheet on every render and duplicates the same rules each time the component is mounted more than once. Move the `.archived-row` rule to a CSS/less module file (or a shared stylesheet) instead of inline in the JSX.","suggestion_code":null,"existing_code":" <style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>"}
{"path":"apps/admin/src/pages/Rooms/RoomsTable.tsx","start_line":43,"end_line":43,"category":"maintainability","severity":"low","content":"`keys as number[]` is an unsafe type assertion: antd's `rowSelection.onChange` yields `Key[]` where `Key = string | number`. It only works today because room ids happen to be numbers; if `rowKey=\"id\"` ever returns string ids (e.g. UUIDs), the assertion silently hides the mismatch and parent code typed as `number[]` would receive strings. Prefer typing `selectedRowKeys`/`onSelect` as `React.Key[]` or assert only after a runtime check.","suggestion_code":null,"existing_code":" onChange: (keys) => onSelect(keys as number[]),"}
{"path":"apps/admin/src/pages/Rooms/RoomsTable.tsx","start_line":41,"end_line":44,"category":"bug","severity":"low","content":"The controlled `rowSelection` relies on antd's default behavior: when the data source changes (pagination, filtering/search), keys on other pages are dropped unless `preserveSelectedRowKeys` is set. Since selection feeds batch archive/restore actions, cross-page selection will be silently lost — consider setting `preserveSelectedRowKeys: true` if batch operations should span pages.","suggestion_code":null,"existing_code":" rowSelection={{\n selectedRowKeys,\n onChange: (keys) => onSelect(keys as number[]),\n }}"}
{"path":"apps/admin/src/pages/Roles/index.tsx","start_line":386,"end_line":389,"category":"bug","severity":"high","content":"Functional bug: each `Checkbox.Group` is an independent component whose `onChange` only reports the checked values *within that group's own options*. Replacing the whole `selectedPermIds` with `vals` therefore wipes out the permission selections of all other groups the moment the user toggles any checkbox in one group (the header “全选” checkboxes are fine because they merge via `setSelectedPermIds(prev => ...)`, but individual permission checkboxes are not). Fix: merge group-local changes into the existing selection, e.g. remove the current group's ids first and then append `vals`.","suggestion_code":" <Checkbox.Group\n value={selectedPermIds}\n onChange={(vals) => {\n const groupIds = new Set(\n allPerms.find((g) => g.group === group.group)?.permissions.map((p) => p.id) ?? [],\n );\n setSelectedPermIds((prev) => [\n ...prev.filter((id) => !groupIds.has(id)),\n ...(vals as number[]),\n ]);\n }}\n >","existing_code":" <Checkbox.Group\n value={selectedPermIds}\n onChange={(vals) => setSelectedPermIds(vals as number[])}\n >"}
{"path":"apps/admin/src/pages/Roles/index.tsx","start_line":66,"end_line":69,"category":"bug","severity":"medium","content":"The queryFn swallows all errors and returns `{ roles: [], permTree: [] }`. This makes react-query treat a failed load as a successful empty result: `isError` is never set (no error/retry UI), the page misleadingly shows the “暂无角色数据” empty state with an “添加角色” action, and with default `refetchOnWindowFocus`/retries the same error toast is re-shown on every refetch. Let the error propagate (or move the toast to `onError`) so the query can properly enter the error state.","suggestion_code":" } catch (e: unknown) {\n throw e;\n }","existing_code":" } catch (e: unknown) {\n message.error(getErrorMessage(e, '加载失败,请稍后重试'));\n return { roles: [], permTree: [] };\n }"}
{"path":"apps/admin/src/pages/Roles/index.tsx","start_line":105,"end_line":105,"category":"bug","severity":"medium","content":"`record.permissions` is not guaranteed to be present: `rolesSchema` (`roleSchema`) only validates `id/name/status` with `.passthrough()`, and the table render code already defensively uses `perms?.map(...)`. Calling `record.permissions.map(...)` here will throw if the backend omits `permissions` for a role. Guard it for consistency.","suggestion_code":" setSelectedPermIds(record.permissions?.map((p) => p.id) ?? []);","existing_code":" setSelectedPermIds(record.permissions.map((p) => p.id));"}
{"path":"apps/admin/src/pages/Roles/index.tsx","start_line":253,"end_line":253,"category":"style","severity":"low","content":"Avoid the `any` type (review rule: no `any` without an explanatory comment). The `_` first parameter of the antd column `render` is unused, so `unknown` is sufficient here.","suggestion_code":" render: (_: unknown, record: RoleItem) => (","existing_code":" render: (_: any, record: RoleItem) => ("}
{"path":"apps/admin/src/pages/Roles/index.tsx","start_line":309,"end_line":316,"category":"maintainability","severity":"low","content":"Static layout styles are inlined (page header container, table/modal wrappers). Per the review rules inline `style` should be reserved for dynamic styles; consider extracting these into a stylesheet/class names.","suggestion_code":null,"existing_code":" style={{\n marginBottom: 16,\n display: 'flex',\n justifyContent: 'space-between',\n alignItems: 'center',\n flexWrap: 'wrap',\n gap: 8,\n }}"}
{"path":"apps/admin/src/pages/Rooms/RoomsToolbar.tsx","start_line":124,"end_line":126,"category":"maintainability","severity":"low","content":"This batch-action block is a nested ternary expression (`showArchived && canEditRooms ? ... : !showArchived && canDeleteRooms ? ... : null`), which is prohibited by the review rules. Extract the logic into a small helper/render function (e.g., `renderBatchActions()`) or compute the action config beforehand and render declaratively, to keep the JSX flat and readable.","suggestion_code":null,"existing_code":" {showArchived && canEditRooms ? (\n <>\n <Popconfirm"}
{"path":"apps/admin/src/pages/Rooms/RoomsToolbar.tsx","start_line":79,"end_line":79,"category":"style","severity":"low","content":"Static inline style on the heading violates the rule against inline `style` attributes (only dynamic styles should use inline style). Move `margin: 0` into a CSS class (e.g., a modifier of `responsive-toolbar__group`).","suggestion_code":"<h3 className=\"responsive-toolbar__title\">宿舍管理</h3>","existing_code":"<h3 style={{ margin: 0 }}>宿舍管理</h3>"}
{"path":"apps/admin/src/pages/Rooms/RoomsToolbar.tsx","start_line":3,"end_line":3,"category":"maintainability","severity":"medium","content":"Importing `UploadRequestOption` from `@rc-component/upload/lib/interface` reaches into antd's internal transitive dependency. This is not a public API and can break silently on antd or rc-component upgrades. Prefer antd's own re-exported type or define a minimal local interface matching the subset actually used (e.g., `{ file, onSuccess, onError }`).","suggestion_code":null,"existing_code":"import type { UploadRequestOption } from '@rc-component/upload/lib/interface';"}
{"path":"apps/admin/src/pages/Rooms/RoomsToolbar.tsx","start_line":133,"end_line":140,"category":"maintainability","severity":"low","content":"The three batch operations (restore / purge / archive) repeat the same `Popconfirm` wrapping a `disabled`+`loading` `Button` structure; only title, icon, and danger/ok props differ. Consider extracting a small `renderBatchButton({ title, okText, icon, danger, okButtonProps, onConfirm })` helper to remove the duplication and make the disabled/enabled state handling consistent in one place.","suggestion_code":null,"existing_code":" <Button\n type=\"primary\"\n icon={<UndoOutlined />}\n disabled={selectedRowKeys.length === 0}\n loading={batchLoading}\n >\n 批量恢复\n </Button>"}
{"path":"apps/admin/src/pages/StudentProfile/index.tsx","start_line":18,"end_line":22,"category":"security","severity":"medium","content":"`document.write()` is explicitly prohibited by the security checklist (page reflow + XSS surface). The HTML from `/archive/:id/report-html` embeds student data; if the server template fails to escape any field, arbitrary script can execute in the opened window. Also, when `window.open` returns null (popup blocked) the user receives no feedback. Prefer navigating the window to a Blob URL instead, e.g.: `const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const w = window.open(url, '_blank');` and revoke the URL after the window loads (or after a timeout).","suggestion_code":" const blob = new Blob([html], { type: 'text/html' });\n const url = URL.createObjectURL(blob);\n const w = window.open(url, '_blank');\n if (!w) {\n message.error('预览报告被浏览器拦截,请允许弹出窗口');\n } else {\n window.setTimeout(() => URL.revokeObjectURL(url), 60_000);\n }","existing_code":" const w = window.open('', '_blank');\n if (w) {\n w.document.write(html);\n w.document.close();\n }"}
{"path":"apps/admin/src/pages/StudentProfile/index.tsx","start_line":23,"end_line":25,"category":"bug","severity":"low","content":"The catch block only logs to the console, so the user gets no feedback when the report fails to load (and the popup-blocked case above is also silent). Per the async error-handling standard, show a user-friendly message, e.g. import `message` from antd and call `message.error('预览报告加载失败,请稍后重试')`.","suggestion_code":" } catch (err) {\n console.error('Failed to load report HTML:', err);\n message.error('预览报告加载失败,请稍后重试');\n }","existing_code":" } catch (err) {\n console.error('Failed to load report HTML:', err);\n }"}
{"path":"apps/admin/src/pages/Rooms/index.tsx","start_line":431,"end_line":435,"category":"bug","severity":"medium","content":"Race condition / stale data when switching rooms in the drawer: `onView` triggers `fetchBeds`/`fetchLockers` asynchronously without tying the result to the room being viewed. If the user opens room A then quickly opens room B, the responses may resolve out of order and `setBeds`/`setLockers` will end up showing room A's data under room B's header. The same stale data also remains visible when reopening a drawer until the new fetch completes. Consider guarding with the requested room id (e.g. capture `const roomId = record.id` and only `setBeds` if `drawerRoom?.id === roomId`), or clearing beds/lockers when opening a drawer.","suggestion_code":null,"existing_code":" onView: (record) => {\n setDrawerRoom(record);\n setDrawerOpen(true);\n void Promise.all([fetchBeds(record.id), fetchLockers(record.id)]);\n },"}
{"path":"apps/admin/src/pages/Rooms/index.tsx","start_line":34,"end_line":34,"category":"maintainability","severity":"medium","content":"The `any` type is used pervasively without justification comments: `useState<any>(null)` for `editing`/`drawerRoom`/`bedEditing`/`lockerEditing`, `useQuery<any[]>`, `params: any` in the queryFn, and `res: any` in `handleBatchDelete`/`handleBatchPurge`. This disables type checking over a large portion of the page (the record is passed to columns, forms and mutations). Prefer defining a `Room`/`BedItem`/`LockerItem` type (or reuse the existing schema types) so errors are caught at compile time; if `any` is truly necessary, add a brief comment explaining why.","suggestion_code":null,"existing_code":" const [editing, setEditing] = useState<any>(null);"}
{"path":"apps/admin/src/pages/Rooms/index.tsx","start_line":204,"end_line":214,"category":"maintainability","severity":"low","content":"`saveBedCell`/`saveLockerCell` reference `fetchBeds`/`fetchLockers` which are declared later in the file (const declarations in the temporal dead zone). This works only because the closures are invoked after render completes, but it is fragile and will trip `no-use-before-define` lint rules. Move the `fetchBeds`/`fetchLockers` definitions (the `useCallback` hooks) above the cell-save handlers for clarity.","suggestion_code":null,"existing_code":" const saveBedCell = async (record: BedItem, field: string, value: unknown) => {\n if (!drawerRoom) return;\n try {\n await saveBedCellMutation.mutateAsync({\n roomId: drawerRoom.id,\n bedId: record.id,\n field,\n value,\n });\n message.success('已保存');\n await fetchBeds(drawerRoom.id);"}
{"path":"apps/admin/src/pages/Schedules/ScheduleGrids.tsx","start_line":79,"end_line":81,"category":"bug","severity":"medium","content":"Empty-state condition checks `classrooms.length === 0`, but the week-view table actually renders `filteredClassrooms` (and the parent passes a filtered subset when the classroom filter is active). When the filter excludes every classroom, `classrooms` is non-empty while `filteredClassrooms` is empty, so the week view silently renders a header-only table with no empty-state feedback. Use `filteredClassrooms.length === 0` (or add a dedicated message like “无符合条件的教室”) so the user gets feedback when the filter yields no rows.","suggestion_code":null,"existing_code":" {classrooms.length === 0 ? (\n <QueryEmpty description=\"暂无教室数据,可在「教室管理」中添加教室后开始排课\" />\n ) : viewMode === 'week' ? ("}
{"path":"apps/admin/src/pages/Schedules/ScheduleGrids.tsx","start_line":79,"end_line":81,"category":"maintainability","severity":"low","content":"This is a nested ternary (`classrooms.length === 0 ? A : viewMode === 'week' ? B : C`), which violates the no-nested-ternary rule and hurts readability. Extract the week/month branches into local variables or use early returns before the JSX, e.g. `if (classrooms.length === 0) return <QueryEmpty .../>;` and then branch on `viewMode === 'week'` separately.","suggestion_code":null,"existing_code":" {classrooms.length === 0 ? (\n <QueryEmpty description=\"暂无教室数据,可在「教室管理」中添加教室后开始排课\" />\n ) : viewMode === 'week' ? ("}
{"path":"apps/admin/src/pages/Schedules/ScheduleGrids.tsx","start_line":83,"end_line":90,"category":"maintainability","severity":"low","content":"Most styles in this table are static (width/border/fontSize/padding, etc.) rather than dynamic, which contradicts the rule to avoid inline styles except for dynamic ones. Consider moving the static styles into CSS classes or shared style constants to reduce repetition between the week/month branches (note the file already carries a `duplicate-block` ignore comment due to this duplication).","suggestion_code":null,"existing_code":" <table\n style={{\n width: '100%',\n borderCollapse: 'collapse',\n fontSize: 13,\n tableLayout: 'fixed',\n }}\n >"}
{"path":"apps/admin/src/pages/Schedules/ScheduleModals.tsx","start_line":80,"end_line":82,"category":"bug","severity":"high","content":"The `useDirtyGuard` hook returns a freshly-created object literal `{ confirmClose, snapshot, isDirty }` on every render (see `useDirtyGuard.ts`). Because this entire object is in the effect's dependency array, this effect re-runs after *every* render of `ScheduleModal` while `open && mode !== 'detail'`, calling `scheduleGuard.snapshot()` each time. That resets the pristine baseline to the current form values, so the dirty-guard's `isDirty` check nearly always returns false and unsaved changes are silently discarded when closing the modal — the exact protection the guard is meant to provide. Since `snapshot` is memoized with `useCallback` and is stable, depend on it instead of the wrapper object.","suggestion_code":" useEffect(() => {\n if (open && mode !== 'detail') scheduleGuard.snapshot();\n }, [open, mode, editingSchedule, scheduleGuard.snapshot]);","existing_code":" useEffect(() => {\n if (open && mode !== 'detail') scheduleGuard.snapshot();\n }, [open, mode, editingSchedule, scheduleGuard]);"}
{"path":"apps/admin/src/pages/Schedules/ScheduleModals.tsx","start_line":84,"end_line":89,"category":"maintainability","severity":"low","content":"Nested ternary expressions are disallowed by the review rules. This 3-level nested ternary is hard to read; extract it into a helper (e.g., a `getTitle()` function or a `switch` on `mode`) so each branch is a single flat expression.","suggestion_code":null,"existing_code":" const title =\n mode === 'create'\n ? `新增排课 — ${selectedClassroom?.name || ''} · ${\n selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''\n }`\n : mode === 'edit'"}
{"path":"apps/admin/src/pages/Schedules/ScheduleModals.tsx","start_line":106,"end_line":106,"category":"maintainability","severity":"low","content":"Nested ternary expression is disallowed by the review rules. Use a simple lookup/switch instead, e.g. `const okText = mode === 'edit' ? '保存' : mode === 'create' ? '创建' : undefined;` moved out of JSX with a flat expression.","suggestion_code":null,"existing_code":" okText={mode === 'edit' ? '保存' : mode === 'create' ? '创建' : undefined}"}
{"path":"apps/admin/src/pages/Students/StudentModals.tsx","start_line":113,"end_line":113,"category":"maintainability","severity":"medium","content":"Inconsistent with the sensitive-label convention used elsewhere in this same file: the `phone` and `emergencyPhone` fields use `SENSITIVE_LABELS.*`, and `StudentColumns.tsx` also uses `SENSITIVE_LABELS.idNumber` for this field, but here the label is hardcoded as \"身份证\" (also slightly different from the canonical \"身份证号\"). Use `SENSITIVE_LABELS.idNumber` for consistency so the centralized masking convention is not bypassed for the most sensitive field.","suggestion_code":"<Form.Item name=\"idNumber\" label={SENSITIVE_LABELS.idNumber}>","existing_code":"<Form.Item name=\"idNumber\" label=\"身份证\">"}
{"path":"apps/admin/src/pages/Students/StudentModals.tsx","start_line":151,"end_line":153,"category":"maintainability","severity":"low","content":"These status options duplicate the already-exported `STUDENT_STATUS_OPTIONS` constant from `StudentColumns.tsx` (same values/labels). Importing and reusing the shared constant avoids the two copies drifting apart when status values are added or renamed.","suggestion_code":null,"existing_code":"{ value: 'active', label: '在读' },\n{ value: 'graduated', label: '已毕业' },\n{ value: 'withdrawn', label: '已退训' },"}
{"path":"apps/admin/src/pages/Students/StudentModals.tsx","start_line":12,"end_line":12,"category":"other","severity":"low","content":"The `message` field in the result type is declared but never read in either `showCreateImportResult` or `showUpdateImportResult` — if the backend returns an error/warning message it will be silently dropped. Either render it in the modal content or remove it from the type to avoid dead API data.","suggestion_code":null,"existing_code":"result: { message?: string; imported?: number; skipped?: number },"}
{"path":"apps/admin/src/pages/Students/StudentModals.tsx","start_line":25,"end_line":25,"category":"style","severity":"low","content":"Per the review rules, static (non-dynamic) inline styles should be avoided. These `style={{...}}` props (marginTop/fontWeight/color/fontSize) are constant styling that should live in a stylesheet or a className instead, keeping JSX declarative and themeable.","suggestion_code":null,"existing_code":"<div style={{ marginTop: 12, fontWeight: 600 }}>跳过原因:</div>"}
{"path":"apps/admin/src/pages/Students/StudentsTable.tsx","start_line":157,"end_line":160,"category":"bug","severity":"medium","content":"Error handling in onExpand swallows failures silently: on error it stores `[]`, which is indistinguishable from a legitimately empty result (renders \"当前仅 0 个班型,无可对比数据\"), provides no user-friendly error message, and — because `[]` is now cached in `enrollmentData` — re-expanding the row will never retry the request. Also, if the user collapses and re-expands while a request is still in flight, `enrollmentData[record.id]` is still undefined so a duplicate request fires (out-of-order responses can overwrite). Suggest storing an error sentinel (e.g. a separate `error` field per id or `{ status: 'error' }`), showing a retry-able error UI, logging the error, and guarding against in-flight duplicates.","suggestion_code":null,"existing_code":" setEnrollmentData((prev) => ({ ...prev, [record.id]: res.enrollments }));\n } catch {\n setEnrollmentData((prev) => ({ ...prev, [record.id]: [] }));\n }"}
{"path":"apps/admin/src/pages/Students/StudentsTable.tsx","start_line":154,"end_line":156,"category":"maintainability","severity":"low","content":"Hardcoded business URL path inline in the component. Per project review rules, business-related URL paths should not be hardcoded; extract API path constants into a central place (e.g. an api paths module) and reuse.","suggestion_code":null,"existing_code":" const res = await api.get<{ enrollments: EnrollmentInfo[] }>(\n `/students/${record.id}/compare-classes`,\n );"}
{"path":"apps/admin/src/pages/Students/StudentsTable.tsx","start_line":19,"end_line":21,"category":"maintainability","severity":"low","content":"`any[]` / `any` types used for the table props and record (`columns: any[]`, `data: any[]`, `record: any`, `keys as number[]`). Per review rules, avoid `any` unless justified with a comment — type these with `TableProps<T>['columns']`, a real `Student` row interface, and `TableProps<T>['rowSelection']['onChange']` to keep type safety (the `keys as number[]` cast in particular masks antd's `Key[]` which may contain strings).","suggestion_code":null,"existing_code":" columns: any[];\n data: any[];\n loading: boolean;"}
{"path":"apps/admin/src/pages/Students/StudentsTable.tsx","start_line":165,"end_line":165,"category":"style","severity":"low","content":"A `<style>` element is injected into the render output and several static inline `style` attributes are used (e.g. the loading div, card backgrounds, margin). Per review rules, static styles should live in a CSS/less file (or CSS modules) and inline styles should be reserved for dynamic values only. Moving `.archived-row` and these layout styles to a stylesheet avoids duplicating style elements on every render.","suggestion_code":null,"existing_code":" <style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>"}
{"path":"apps/admin/src/pages/Students/StudentColumns.tsx","start_line":261,"end_line":265,"category":"style","severity":"low","content":"Nested ternary is not allowed by the review rules: `canChooseOrganization ? ... : organization?.name ? ... : '-'`. Extract this into a local helper function (e.g. `renderOrganization`) or an if/else block to keep the render logic flat and readable.","suggestion_code":" ) : organization?.name ? (\n <Tag color=\"purple\">{organization.name}</Tag>\n ) : (\n '-'\n ),","existing_code":" ) : organization?.name ? (\n <Tag color=\"purple\">{organization.name}</Tag>\n ) : (\n '-'\n ),"}
{"path":"apps/admin/src/pages/Students/StudentColumns.tsx","start_line":46,"end_line":49,"category":"maintainability","severity":"medium","content":"The `any` type is used pervasively without a justification comment (review rule: avoid `any`; if necessary explain why). `record: any` in `onSaveCell`/`onEdit` and in every column `render` loses type safety (e.g. `record.status`, `record.organizationId`, `record.name` are all unchecked). Define a `StudentRecord` interface (id/name/status/phone/idNumber/organizationId etc.) and use it for the ctx callbacks and render params.","suggestion_code":null,"existing_code":" onSaveCell: (record: any, field: string, value: unknown) => Promise<void> | void;\n onViewSensitive: (recordId: number, field: string, value: string) => void;\n onOpenDrawer: (recordId: number) => void;\n onEdit: (record: any) => void;"}
{"path":"apps/admin/src/pages/Students/StudentColumns.tsx","start_line":105,"end_line":106,"category":"style","severity":"low","content":"Static inline styles (e.g. `display: 'inline-flex'`, `padding: '8px 4px'`, `fontSize: 12`, `maxWidth: '100%'`) violate the review rule against inline styles except for dynamic values. Move them into a CSS module / classNames so style and layout are centralized and reusable.","suggestion_code":null,"existing_code":" <span style={{ display: 'inline-flex', alignItems: 'center', whiteSpace: 'nowrap' }}>\n <span style={{ marginRight: 4 }}>{masked}</span>"}
{"path":"apps/admin/src/pages/Schedules/index.tsx","start_line":343,"end_line":345,"category":"bug","severity":"high","content":"Bug: this guard blocks creating a schedule from the month view. In month view, `handleDateClick` opens the detail modal with `selectedCell = null` (only `selectedDate` is set), and the detail modal's \"新增排课\" button calls `onStartCreate`, which switches to `modalMode === 'create'` while keeping `selectedCell` null. `handleSubmit` then returns early and submission silently fails (no message). The guard should also allow creation when `selectedDate` is present.","suggestion_code":" const handleSubmit = async () => {\n if (modalMode === 'create' && !selectedCell && !selectedDate) return;\n if (modalMode === 'edit' && !editingSchedule) return;","existing_code":" const handleSubmit = async () => {\n if (modalMode === 'create' && !selectedCell) return;\n if (modalMode === 'edit' && !editingSchedule) return;"}
{"path":"apps/admin/src/pages/Schedules/index.tsx","start_line":315,"end_line":326,"category":"bug","severity":"medium","content":"Race condition: `loadClassTeachers` is fired as fire-and-forget from `openEditSchedule` (and on class change / subject blur). If the user quickly opens/edits schedules of different classes, responses can resolve out of order and `setClassTeachers` will populate the form with the teacher list of a previous class. Guard against stale results, e.g. by tracking a request sequence id (only apply the response if it is still the latest requested classId) or by using an AbortController.","suggestion_code":null,"existing_code":" const loadClassTeachers = async (classId: number) => {\n try {\n const teachers = await api.get<ClassTeacherOption[]>(\n `/class-schedules/classes/${classId}/teachers`,\n );\n setClassTeachers(teachers);\n return teachers;\n } catch {\n setClassTeachers([]);\n return [];\n }\n };"}
{"path":"apps/admin/src/pages/Schedules/index.tsx","start_line":84,"end_line":88,"category":"bug","severity":"low","content":"The API responses carry a `success` flag that is never checked. If the backend returns `success: false` (business-level failure without an HTTP error), `setSyncStatus(res.data)` / `setSyncResult(res.data)` will render the status as if the operation succeeded. Validate `res.success` and surface the failure message instead. The same pattern exists in `handleSyncSchedule`.","suggestion_code":" const res = await api.get<{\n success: boolean;\n data: { activeSchedules: number; mappedClasses: number; totalClasses: number };\n }>('/sync/schedule/status');\n if (!res.success) {\n setSyncStatusError(true);\n setSyncStatus(null);\n return;\n }\n setSyncStatus(res.data);","existing_code":" const res = await api.get<{\n success: boolean;\n data: { activeSchedules: number; mappedClasses: number; totalClasses: number };\n }>('/sync/schedule/status');\n setSyncStatus(res.data);"}
{"path":"apps/admin/src/pages/Schedules/index.tsx","start_line":433,"end_line":442,"category":"maintainability","severity":"low","content":"Per the review rules, static inline styles should be avoided (only dynamic styles may be inline). This page uses many static `style={{...}}` objects (header container, date range span, filter card, etc.); consider moving them to a CSS module / stylesheet for maintainability.","suggestion_code":null,"existing_code":" <div\n style={{\n marginBottom: 16,\n display: 'flex',\n justifyContent: 'space-between',\n alignItems: 'center',\n flexWrap: 'wrap',\n gap: 8,\n }}\n >"}
{"path":"apps/admin/src/ui/AppMessageBridge.tsx","start_line":8,"end_line":10,"category":"bug","severity":"medium","content":"The effect binds `messageApi` to a module-level singleton (`messageApi` in `app-message.ts`) but never unbinds it. If `AppMessageBridge` ever unmounts (React StrictMode/HMR in dev, tests, or a future conditional render), the singleton keeps pointing at a detached `MessageInstance`, so every subsequent `message.success/error/warning` call silently fails to show a notification (or throws) without any signal. Return a cleanup from the effect that resets the binding — e.g., add an `unbindMessageApi` export in `app-message.ts` and call it in the effect's cleanup function.","suggestion_code":" useEffect(() => {\n bindMessageApi(messageApi);\n return () => unbindMessageApi(messageApi);\n }, [messageApi]);","existing_code":" useEffect(() => {\n bindMessageApi(messageApi);\n }, [messageApi]);"}
{"path":"apps/admin/src/pages/TeacherWorkspace/index.tsx","start_line":71,"end_line":71,"category":"performance","severity":"medium","content":"`loading = isLoading || isFetching` means any background refetch (react-query defaults to refetchOnWindowFocus/refetchOnReconnect) will replace the fully rendered workspace with a Skeleton, causing a full-page flicker and loss of interaction/scroll state on every tab focus. Use `isLoading` (initial load) or gate on missing cached data, e.g. `const loading = isLoading || (isFetching && !data);`.","suggestion_code":" const loading = isLoading || (isFetching && !data);","existing_code":" const loading = isLoading || isFetching;"}
{"path":"apps/admin/src/pages/TeacherWorkspace/index.tsx","start_line":113,"end_line":117,"category":"maintainability","severity":"low","content":"The ternary treats every value other than 'INTERNAL' as rental ('租赁'), so any new/unknown scheduleType returned by the backend will be silently mislabeled. Prefer an explicit label map with a fallback, e.g. a `SCHEDULE_TYPE_MAP: Record<string, { label: string; color: string }>` and render `SCHEDULE_TYPE_MAP[v]?.label ?? v`.","suggestion_code":null,"existing_code":" render: (v: string) => (\n <Tag color={v === 'INTERNAL' ? 'blue' : 'orange'}>\n {v === 'INTERNAL' ? '内部课程' : '租赁'}\n </Tag>\n ),"}
{"path":"apps/admin/src/pages/TeacherWorkspace/index.tsx","start_line":145,"end_line":145,"category":"style","severity":"low","content":"Static inline style (constant value, not dynamic) is discouraged per project conventions; move `padding: 24` into a CSS class / antd token.","suggestion_code":null,"existing_code":" <div style={{ padding: 24 }}>"}
{"path":"apps/admin/src/pages/Students/index.tsx","start_line":541,"end_line":541,"category":"bug","severity":"medium","content":"Pagination is not reset when the search keyword, filters (status/class/teacher/org), or the archived toggle change. The list query refetches with a new queryKey but `pageInfo.current` keeps its old value, so if the current page is beyond the page count of the new result set, the table renders empty/out-of-range data. Reset `pageInfo.current` to 1 in every filter/search/toggle handler (or in an effect watching the filter values).","suggestion_code":" onSearchName={(name) => {\n setSearchName(name);\n setPageInfo((prev) => ({ ...prev, current: 1 }));\n }}","existing_code":" onSearchName={setSearchName}"}
{"path":"apps/admin/src/pages/Students/index.tsx","start_line":468,"end_line":470,"category":"bug","severity":"medium","content":"The `else` branch treats every non-'partial' status as success. If the backend reports status `'error'`/`'failed'`, the error message would be shown in a `message.success` toast, misleading users into believing the sync succeeded. Explicitly handle an error status with `message.error`.","suggestion_code":" } else if (log?.status === 'error') {\n message.error(log?.errorMessage || '钉钉同步失败');\n } else {\n message.success(\n log?.errorMessage || `钉钉同步完成,共处理 ${log?.recordsCount ?? res.synced} 条`,\n );\n }","existing_code":" message.success(\n log?.errorMessage || `钉钉同步完成,共处理 ${log?.recordsCount ?? res.synced} 条`,\n );"}
{"path":"apps/admin/src/pages/Students/index.tsx","start_line":85,"end_line":85,"category":"maintainability","severity":"low","content":"`any` is used for the `editing` state (and similarly for `saveCell`'s `record: any` and the import handlers' `{ file, onSuccess, onError }: any`), which bypasses type checking on the record fields accessed later (e.g., `editing.id`, `record.id`). Define a concrete Student record type (or reuse the schema-derived row type) for these; if `any` is unavoidable, add a comment explaining why.","suggestion_code":null,"existing_code":" const [editing, setEditing] = useState<any>(null);"}
{"path":"apps/admin/src/pages/Students/index.tsx","start_line":301,"end_line":301,"category":"bug","severity":"low","content":"`form.validateFields()` rejects when validation fails, and this call sits outside the try/catch, producing an unhandled promise rejection (and possible uncaught error log) when the user submits an invalid form. Wrap the validation in its own try/catch and return early on failure.","suggestion_code":" let values: Record<string, unknown>;\n try {\n values = await form.validateFields();\n } catch {\n return;\n }\n setSaving(true);","existing_code":" const values = await form.validateFields();"}
{"path":"apps/admin/src/pages/Students/index.tsx","start_line":571,"end_line":577,"category":"maintainability","severity":"low","content":"The \"add student\" logic (reset form, prefill host organization, open modal) is duplicated verbatim in both `StudentsToolbar`'s `onAddStudent` and `StudentsTable`'s `onAddStudent`. Extract a single `openCreateModal` callback and reuse it in both places so the prefill behavior cannot diverge.","suggestion_code":null,"existing_code":" onAddStudent={() => {\n setEditing(null);\n form.resetFields();\n const host = organizations.find((organization) => organization.isHost);\n if (host) form.setFieldValue('organizationId', host.id);\n setModalOpen(true);\n }}"}
{"path":"apps/admin/src/pages/Students/index.tsx","start_line":472,"end_line":472,"category":"maintainability","severity":"low","content":"Hardcoded `['students']` query key is used here (and again in `JinshujuModal`'s `onApplied`), while the rest of the page uses the centralized `queryKeys.students.all`. Use `queryKeys.students.all` (or the same `invalidateStudents` array used by the mutations) so cache invalidation stays consistent if the key structure changes.","suggestion_code":" void queryClient.invalidateQueries({ queryKey: queryKeys.students.all });","existing_code":" void queryClient.invalidateQueries({ queryKey: ['students'] });"}
{"path":"apps/admin/src/pages/Users/index.tsx","start_line":38,"end_line":38,"category":"maintainability","severity":"medium","content":"This file uses `any` extensively (`editing`, `resetTarget`, `profileUser`, `record` parameters, the query generic, etc.) without any justification comment. Per the review rules `any` should be avoided. Define shared types (e.g. `UserRecord`/`Role`) and reuse `UserProfileResponse` (already imported from `user-profile-form`) so the table rows, handlers and mutations are type-safe.","suggestion_code":" const [editing, setEditing] = useState<UserRecord | null>(null);","existing_code":" const [editing, setEditing] = useState<any>(null);"}
{"path":"apps/admin/src/pages/Users/index.tsx","start_line":98,"end_line":101,"category":"bug","severity":"medium","content":"Errors are caught inside the `queryFn` and converted into empty results plus a toast. This swallows the query's error state (`isError` is never set), and because TanStack Query retries failed queries by default (3 retries) and mutations invalidate `['rbac', 'users']` to trigger background refetches, a transient failure can produce multiple `加载失败,请稍后重试` toasts in a row (one per retry/refetch). Prefer throwing from `queryFn` and handling the error once at the query level (error state/`onError`), or set `retry: false` and toast once here.","suggestion_code":" } catch (e: unknown) {\n // 由 useQuery 的 error 状态统一处理,避免重试/后台刷新时重复弹 toast\n throw e;\n }","existing_code":" } catch (e: unknown) {\n message.error(getErrorMessage(e, '加载失败,请稍后重试'));\n return { users: [], roles: [] };\n }"}
{"path":"apps/admin/src/pages/Users/index.tsx","start_line":24,"end_line":30,"category":"maintainability","severity":"low","content":"`USER_FIELDS.phone`, `USER_FIELDS.email` and `USER_FIELDS.status` are declared but never referenced anywhere in this file (only `username` and `name` are used as column `dataIndex`). Remove the unused entries or actually use them, otherwise they are dead constants that mislead readers into thinking these fields are editable here.","suggestion_code":"const USER_FIELDS = {\n username: 'username',\n name: 'name',\n} as const;","existing_code":"const USER_FIELDS = {\n username: 'username',\n name: 'name',\n phone: 'phone',\n email: 'email',\n status: 'status',\n} as const;"}
{"path":"apps/admin/src/pages/Users/index.tsx","start_line":161,"end_line":162,"category":"bug","severity":"low","content":"`form.validateFields()` is awaited outside the try/catch in `handleSubmit` (same pattern in `handleProfileSubmit` and `handlePwdSubmit`). When validation fails it rejects, producing an unhandled promise rejection from the Modal's `onOk`. antd already shows the field errors, so wrap the validation (or the whole flow) in try/catch so the rejection is handled explicitly.","suggestion_code":" const handleSubmit = async () => {\n let values: any;\n try {\n values = await form.validateFields();\n } catch {\n // 校验失败antd 已展示错误提示\n return;\n }","existing_code":" const handleSubmit = async () => {\n const values = await form.validateFields();"}
{"path":"apps/admin/src/pages/Users/index.tsx","start_line":91,"end_line":91,"category":"maintainability","severity":"low","content":"API endpoint paths are hardcoded inline in many places (`/rbac/users`, `/rbac/roles`, `/rbac/users/${id}/profile`, `.../password`, `.../permanent`, `.../archive`/`restore`). Per the review rules business URL paths should not be hardcoded; centralize them in the `api` module so endpoint names stay consistent and are easier to adjust/version.","suggestion_code":null,"existing_code":" api.get(`/rbac/users?isArchived=${showArchived}`) as Promise<any[]>,"}
{"path":"apps/admin/src/pages/Users/index.tsx","start_line":264,"end_line":266,"category":"style","severity":"low","content":"Several inline `style` attributes are used (page-header wrapper, the Switch wrapper span, the username cell span, etc.). Per the review rules inline styles should be reserved for dynamic values only; extract static layout styles into classNames/CSS instead.","suggestion_code":null,"existing_code":" <span\n title={v}\n style={{"}
{"path":"apps/admin/src/pages/Students/StudentsToolbar.tsx","start_line":263,"end_line":264,"category":"maintainability","severity":"medium","content":"`onUpdateImport` is declared as `UploadProps['customRequest']`, which only accepts a single `options` parameter, yet it is invoked here with a second fabricated argument `{ defaultRequest: () => undefined }`. This is a type mismatch (TS2554: expected 1 argument, got 2) whenever type-checking is enabled, and the comment itself admits the argument only exists to satisfy an external signature. If the underlying implementation truly has a two-parameter signature, define an explicit prop type (e.g. `(options: UploadRequestOption, info?: { defaultRequest: () => void }) => void`) and keep the interfaces in sync; otherwise drop the second argument entirely.","suggestion_code":null,"existing_code":" // 实现方只使用 { file, onSuccess, onError }info 参数仅用于满足类型签名\n onUpdateImport?.(options, { defaultRequest: () => undefined });"}
{"path":"apps/admin/src/pages/Students/StudentsToolbar.tsx","start_line":254,"end_line":256,"category":"bug","severity":"low","content":"If the user cancels `modal.confirm`, neither `options.onSuccess` nor `options.onError` is ever invoked, so the `Upload` component keeps this file permanently stuck in the 'uploading' state. It is invisible only because `showUploadList={false}`, but the internal state leaks and any success/error feedback is lost. Handle the cancel path (e.g. call `options.onError?.()` when the dialog is dismissed, or intercept selection with `beforeUpload` returning `Upload.LIST_IGNORE`), and wrap the async `onUpdateImport` call in try/catch so failures surface to the user instead of leaving the modal/upload in an unresolved state.","suggestion_code":null,"existing_code":" customRequest={(options) => {\n const file = options.file as File;\n modal.confirm({"}
{"path":"apps/admin/src/pages/Students/StudentsToolbar.tsx","start_line":180,"end_line":182,"category":"maintainability","severity":"low","content":"This renders batch actions with a nested ternary (`showArchived && canEditStudent ? ... : !showArchived && canDeleteStudent ? ... : null`), which the review rules prohibit. Extract the batch-action block into a small helper (e.g. `renderBatchActions()`) using early returns for readability.","suggestion_code":null,"existing_code":" {showArchived && canEditStudent ? (\n <>\n <Popconfirm"}
{"path":"apps/admin/src/pages/Students/StudentsToolbar.tsx","start_line":115,"end_line":116,"category":"style","severity":"low","content":"Static inline `style={{ width: ... }}` attributes are used on several controls. Per the review rules, inline styles should be avoided except for dynamic values; prefer CSS classes on the existing `responsive-toolbar` container or shared constants for these fixed widths.","suggestion_code":null,"existing_code":" allowClear\n style={{ width: 250 }}"}
{"path":"apps/admin/src/pages/Students/StudentsToolbar.tsx","start_line":248,"end_line":248,"category":"security","severity":"low","content":"Unlike sibling actions ('添加学生', '导出名单', '同步金数据') which are gated by `PermissionButton` or `canSync*` props, the '导入Excel' and '更新已有学生资料' Upload buttons are always rendered without any permission guard. Verify the parent enforces authorization before wiring `onCreateImport`/`onUpdateImport`; otherwise add a `canImport`-style prop so unauthorized users cannot trigger these destructive data-modifying actions.","suggestion_code":null,"existing_code":" <Upload accept=\".xlsx,.xls\" showUploadList={false} customRequest={onCreateImport}>"}
{"path":"apps/admin/src/pages/Teachers/index.tsx","start_line":120,"end_line":120,"category":"bug","severity":"medium","content":"Clearing the date field cannot be persisted: when the user clears `joinedAt`, `values.joinedAt` is `null` and this expression yields `undefined`. JSON serialization drops `undefined` keys, so the PUT payload will not contain `joinedAt` at all and the server keeps the old value. Send `null` explicitly so the backend can clear it.","suggestion_code":" joinedAt: values.joinedAt ? values.joinedAt.format('YYYY-MM-DD') : null,","existing_code":" joinedAt: values.joinedAt?.format('YYYY-MM-DD'),"}
{"path":"apps/admin/src/pages/Teachers/index.tsx","start_line":107,"end_line":107,"category":"bug","severity":"medium","content":"Same clearing bug on the inline-edit path: `EditableCell`'s date editor serializes a cleared date to `undefined` (see `serializeEditableValue` for 'date' in EditableCell/index.tsx), so `{ [field]: undefined }` drops the `joinedAt` key from the JSON payload and the server retains the previous date. Normalize to `null` before sending.","suggestion_code":" api.put(`/rbac/teachers/${record.id}/profile`, { [field]: value ?? null }),","existing_code":" api.put(`/rbac/teachers/${record.id}/profile`, { [field]: value }),"}
{"path":"apps/admin/src/pages/Teachers/index.tsx","start_line":83,"end_line":83,"category":"maintainability","severity":"low","content":"Business API paths `/rbac/teachers` and `/rbac/teachers/${id}/profile` are hardcoded and duplicated in three places (queryFn and both mutations), making the endpoint contract drift-prone. Centralize them in the api layer (e.g., `api.rbac.teachers.list` / `api.rbac.teachers.updateProfile`) instead of scattering path strings across the page.","suggestion_code":null,"existing_code":" await api.get<TeacherListResponse>('/rbac/teachers', {"}
{"path":"apps/admin/src/pages/Teachers/index.tsx","start_line":165,"end_line":166,"category":"maintainability","severity":"low","content":"Using the array index as the React `key` for classAssignment tags (here and in `expandedRowRender`) can cause incorrect reconciliation when the assignment list changes/reorders after a refetch. Prefer a stable composite key such as `${a.roleType}-${a.className}-${a.subject}`.","suggestion_code":null,"existing_code":" <Tag key={i}>\n {a.className || '-'}"}
{"path":"apps/admin/src/pages/Teachers/index.tsx","start_line":91,"end_line":91,"category":"performance","severity":"low","content":"Binding the Table's `loading` to `isFetching` shows a full-page spinner overlay on every background refetch — including the refetch triggered by `invalidate` after each inline cell save and by `useVisibleRefetch` — which blocks/interrupts an in-progress cell edit and causes table flicker. Use `isLoading` for the Table overlay and keep `isFetching` only for the subtle `RefreshButton` indicator.","suggestion_code":" const loading = isLoading;","existing_code":" const loading = isLoading || isFetching;"}
{"path":"apps/admin/src/pages/Wallets/index.tsx","start_line":336,"end_line":336,"category":"bug","severity":"medium","content":"Batch selection is silently lost when changing pages: antd's `rowSelection` by default prunes `selectedRowKeys` to the rows of the current page, so students selected on page 1 are dropped after the user flips to page 2. The batch operation then applies to fewer students than the user intended, and the success message count (`selectedRowKeys.length`) reflects only the current page. Enable `preserveSelectedRowKeys: true` so selections survive pagination (selection is already correctly cleared on filter changes).","suggestion_code":" rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys, preserveSelectedRowKeys: true }}","existing_code":" rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}"}
{"path":"apps/admin/src/pages/Wallets/index.tsx","start_line":162,"end_line":162,"category":"bug","severity":"low","content":"`mutateAsync` result is typed as `any`; if the request resolves to an empty/undefined body (e.g., 204 or a gateway transform), `result.payments` throws a TypeError that is silently swallowed by the catch block, so a successful operation is reported as a generic failure. Use `result?.payments || []` and define a proper response type (e.g., `{ wallet: ...; payments: { paidAmount: number }[] }`) instead of `any`. The same pattern is repeated in `submitBatchChange`.","suggestion_code":" const result = await changeMutation.mutateAsync<{ payments?: { paidAmount?: number }[] }>({","existing_code":" const result: any = await changeMutation.mutateAsync({"}
{"path":"apps/admin/src/pages/Wallets/index.tsx","start_line":184,"end_line":184,"category":"bug","severity":"low","content":"Same untyped/`any` response handling as in `submitChange`: `result.results` will throw if the body is undefined, and `item.payments`/`bill.paidAmount` are untyped. Use `result?.results || []` and define a typed response shape instead of `any`.","suggestion_code":" const result = await batchChangeMutation.mutateAsync<{ results?: { payments?: { paidAmount?: number }[] }[] }>({","existing_code":" const result: any = await batchChangeMutation.mutateAsync({"}
{"path":"apps/admin/src/pages/Wallets/index.tsx","start_line":39,"end_line":45,"category":"maintainability","severity":"low","content":"`WalletTransaction` is incomplete and unvalidated: the table columns read `billId` and `description` but the interface doesn't declare them, and — unlike the two list queries which go through `validateResponse` — the `/wallets/transactions` response has no schema validation. Malformed data (e.g., missing `amount` or `createdAt`) would crash `value.toFixed(2)` / `dayjs(value)` in the drawer. Add the missing optional fields and validate this response too.","suggestion_code":"interface WalletTransaction {\n id: number;\n createdAt: string;\n type: string;\n amount: number;\n balanceAfter: number;\n billId?: number;\n description?: string;\n}","existing_code":"interface WalletTransaction {\n id: number;\n createdAt: string;\n type: string;\n amount: number;\n balanceAfter: number;\n}"}
{"path":"apps/admin/src/pages/Wallets/index.tsx","start_line":399,"end_line":401,"category":"maintainability","severity":"low","content":"The `type`/`amount`/`description` Form fields (including the same validation rules and helper text) are duplicated between the single and batch modals. Extract them into a shared field set/component or a shared field config so validation rules and copy stay in sync when changed in one place.","suggestion_code":null,"existing_code":" <Form.Item\n name=\"amount\"\n label=\"变动金额(元/人)\""}
{"path":"apps/admin/src/pages/Wallets/index.tsx","start_line":228,"end_line":228,"category":"style","severity":"low","content":"Static presentation is done with inline `style` props (color, marginBottom, padding, background, borderRadius, fontWeight, etc.) throughout the file. Per the review rules, inline styles should be reserved for dynamic values; move static styling to CSS classes or style tokens.","suggestion_code":null,"existing_code":" <div style={{ color: '#999' }}>"}
{"path":"apps/admin/src/pages/Wallets/index.tsx","start_line":367,"end_line":367,"category":"bug","severity":"low","content":"No validation links the amount sign to the operation type: a negative value is accepted for 充值 (recharge) and a positive one for 调账 (adjustment), even though the helper text says recharge must be positive and deductions must be negative. Add conditional rules (e.g., `min: 0.01` when type is `recharge`) so invalid values are rejected before reaching the API.","suggestion_code":null,"existing_code":" rules={[{ required: true, message: '请输入金额' }]}"}
{"path":"apps/server/src/agent-context/business-context.types.ts","start_line":16,"end_line":16,"category":"maintainability","severity":"low","content":"All other collection fields in this file are declared `readonly` to enforce immutability of the shared config, but `options` is a mutable `Array`. This inconsistency allows accidental mutation of the config (e.g., push/sort in callers). Declare it `readonly` for consistency.","suggestion_code":" options?: ReadonlyArray<{ label: string; value: string }>;","existing_code":" options?: Array<{ label: string; value: string }>;"}
{"path":"apps/server/src/agent-tools/agent-skill.catalog.ts","start_line":3,"end_line":5,"category":"maintainability","severity":"low","content":"The `key` values here (e.g. 'overview', 'student') are plain string literals with no compile-time linkage to the `skillKey` used by actual tool definitions (see `ToolDef.skillKey` in agent-tool.types.ts). Since `AgentSkillDescriptor.key` is typed as `string`, a typo or a key that no tool implements will silently produce an empty skill at runtime. Consider using a shared union type / const assertion (e.g. `as const` + `satisfies`) so keys are validated against the set of defined tools.","suggestion_code":null,"existing_code":"export const AGENT_SKILLS: readonly Omit<AgentSkillDescriptor, 'tools'>[] = [\n {\n key: 'overview',"}
{"path":"apps/server/src/agent-context/get-pending-tasks.tool.ts","start_line":20,"end_line":21,"category":"maintainability","severity":"low","content":"The description hardcodes the three workflow keys (`student_teaching / dormitory_billing / classroom_rental`), duplicating `BUSINESS_WORKFLOW_KEYS` which is already imported and used to build the schema enum dynamically. When a new workflow is added to the registry, the enum updates automatically but this LLM-facing description silently drifts out of sync. Consider composing the description from `BUSINESS_WORKFLOW_KEYS` to keep them in lockstep.","suggestion_code":" readonly description =\n `获取当前账号可见范围内的业务待办计数(未分班学生、已建档未入住学生、在住未生成账单、租赁缺合同/临期到期等),可按闭环(${BUSINESS_WORKFLOW_KEYS.join(' / ')})聚焦。写入或导入前用它核实前置数据是否齐备,完成后用它判断后续待办。`;","existing_code":" readonly description =\n '获取当前账号可见范围内的业务待办计数(未分班学生、已建档未入住学生、在住未生成账单、租赁缺合同/临期到期等)。写入或导入前用它核实前置数据是否齐备,完成后用它判断后续待办。';"}
{"path":"apps/server/src/agent-context/get-pending-tasks.tool.ts","start_line":40,"end_line":46,"category":"maintainability","severity":"low","content":"`workflowKey` is matched with exact equality against the enum, but LLM-supplied input can easily carry accidental whitespace (e.g. `\"student_teaching \"`). Such input is rejected with the confusing \"必须是 X 之一\" error even though the value is semantically valid. Other tools normalize string input via `optionalString` (which trims); consider trimming here before the `includes` check.","suggestion_code":" if (\n typeof input.workflowKey !== 'string' ||\n !BUSINESS_WORKFLOW_KEYS.includes(input.workflowKey.trim())\n ) {\n return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` };\n }\n return { ok: true, value: { workflowKey: input.workflowKey.trim() } };","existing_code":" if (\n typeof input.workflowKey !== 'string' ||\n !(BUSINESS_WORKFLOW_KEYS).includes(input.workflowKey)\n ) {\n return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` };\n }\n return { ok: true, value: { workflowKey: input.workflowKey } };"}
{"path":"apps/server/src/agent-context/business-context.service.ts","start_line":155,"end_line":158,"category":"bug","severity":"medium","content":"Inconsistent returned context: entities are collected from the visible workflows' stage.entities, but then additionally filtered by the entity's own requiredPermissions. If a visible workflow references an entity the principal lacks permission for (e.g. a user with `student:view` but not `attendance:view`), the workflow is still returned while the entity definition is dropped. The caller then receives workflows whose `stages[].entities` reference keys absent from the returned `entities` array — and the entity key is already leaked via the workflow anyway. Either return all referenced entities for visible workflows, or drop workflows that reference entities the principal cannot view, so the result is internally consistent.","suggestion_code":null,"existing_code":" const entities = BUSINESS_ENTITIES.filter(\n (entity) => entityKeys.has(entity.key) && hasPermission(principal, entity.requiredPermissions),\n );\n return { workflows, entities };"}
{"path":"apps/server/src/agent-context/business-context.service.ts","start_line":58,"end_line":60,"category":"maintainability","severity":"medium","content":"validateRegistry is incomplete as an integrity check: it detects duplicate entity keys, duplicate field keys, and duplicate stage keys within a workflow, but not duplicate workflow keys across BUSINESS_WORKFLOWS (a duplicate key would make getBusinessContext(principal, workflowKey) silently return multiple workflows) nor duplicate nextSteps step keys (which would make suggestNextSteps return duplicate suggestions). Add checks for both so the registry validator catches these.","suggestion_code":null,"existing_code":" const duplicateEntities = BUSINESS_ENTITIES\n .map((entity) => entity.key)\n .filter((key, index, all) => all.indexOf(key) !== index);"}
{"path":"apps/server/src/agent-context/business-context.service.ts","start_line":86,"end_line":90,"category":"maintainability","severity":"low","content":"`stageKeys` here is a union of stage keys across ALL workflows, so a relation's `requiredFor` reference passes validation as long as the key exists in any workflow — e.g. 'schedule' is a stage in both student_teaching and classroom_rental, and 'profile'/'room' also appear in multiple workflows. This lets cross-workflow stage-key collisions mask a wrong reference (a relation pointing at a stage that doesn't exist in the workflow context where the entity is actually used). Consider validating `requiredFor` against the per-workflow stage keys of the workflow(s) that use this entity.","suggestion_code":null,"existing_code":" for (const stageKey of relation.requiredFor) {\n if (!stageKeys.has(stageKey)) {\n problems.push(`实体 ${entity.key} 关系 ${relation.via} 引用未知阶段: ${stageKey}`);\n }\n }"}
{"path":"apps/server/src/agent-context/get-business-context.tool.ts","start_line":19,"end_line":19,"category":"maintainability","severity":"medium","content":"Description hardcodes business keys (student_teaching / dormitory_billing / classroom_rental) and the entity list that already live in business-context.registry.ts. If a workflow or entity key is renamed/removed in the registry, this LLM-facing prompt silently drifts and the model will keep generating stale keys. Consider composing the description (or the inputSchema description) from BUSINESS_WORKFLOWS/BUSINESS_ENTITIES so keys stay in sync automatically.","suggestion_code":"'获取当前账号可见的业务流程、实体字典、阶段依赖与下一步建议。写入或导入前先调用本工具确认前置数据要求。';","existing_code":"'获取当前账号可见的业务流程(学生教学/住宿计费/教室租赁)、实体字典、阶段依赖与下一步建议。写入或导入前先调用本工具确认前置数据要求。';"}
{"path":"apps/server/src/agent-context/get-business-context.tool.ts","start_line":42,"end_line":43,"category":"maintainability","severity":"low","content":"The synchronous service call is wrapped in Promise.resolve instead of using async/await. Since both service methods are synchronous, making these handlers `async execute(...)` and returning the value directly is simpler, clearer, and matches the project's preference for async/await.","suggestion_code":" async execute(input: GetBusinessContextInput, context: AgentToolContext): Promise<unknown> {\n return this.service.getBusinessContext(","existing_code":" execute(input: GetBusinessContextInput, context: AgentToolContext): Promise<unknown> {\n return Promise.resolve("}
{"path":"apps/server/src/agent-context/get-business-context.tool.ts","start_line":76,"end_line":78,"category":"bug","severity":"low","content":"The error message 'entityKey 必须是字符串' is misleading: this branch also catches empty/whitespace-only strings and strings longer than 50 chars (before trim). A caller passing an empty string gets a message claiming the type is wrong. Split the checks so each failure returns an accurate message (e.g. 'entityKey 不能为空' for empty/whitespace-only, 'entityKey 长度不能超过 50' for length).","suggestion_code":" if (typeof input.entityKey !== 'string') {\n return { ok: false, error: 'entityKey 必须是字符串' };\n }\n if (!input.entityKey.trim()) {\n return { ok: false, error: 'entityKey 不能为空' };\n }","existing_code":" if (typeof input.entityKey !== 'string' || !input.entityKey.trim()) {\n return { ok: false, error: 'entityKey 必须是字符串' };\n }"}
{"path":"apps/server/src/agent-context/get-business-context.tool.ts","start_line":37,"end_line":37,"category":"other","severity":"low","content":"Unknown keys are silently accepted: a typo'd workflowKey yields an empty workflow list and a typo'd entityKey yields null, both as 'success'. For an LLM agent this is a silent failure that wastes a turn and may make the model conclude the data does not exist. Consider validating the key against the registry and returning an explicit error for unknown keys (while still returning empty/null for known-but-forbidden keys to avoid leaking existence).","suggestion_code":null,"existing_code":" const workflowKey = optionalString(input.workflowKey, 'workflowKey', 50);"}
{"path":"apps/server/src/agent-tools/agent-tool.registry.ts","start_line":30,"end_line":30,"category":"maintainability","severity":"medium","content":"The permission subject string `PermissionCode:${tool.requiredPermission}` is hand-built here, duplicating the exported helper `permissionCodeSubject()` from `../authorization/casl.constants` (which the executor's execute-time check goes through via `AuthorizationService.canPermission`). If the subject format ever changes, this listing filter and the execute-time authorization check will silently diverge — a tool could disappear from listings or, worse, be listed without matching the execute-time gate. Use the shared helper instead to keep a single source of truth. (The explicit super-admin branch is also redundant: a `manage all` ability already satisfies `ability.can(Access, ...)` in CASL, but keeping it is harmless.)","suggestion_code":" return ability.can(CaslAction.Access, permissionCodeSubject(tool.requiredPermission));","existing_code":" return ability.can(CaslAction.Access, `PermissionCode:${tool.requiredPermission}`);"}
{"path":"apps/server/src/agent-tools/agent-tool.registry.ts","start_line":12,"end_line":17,"category":"maintainability","severity":"medium","content":"`register()` silently replaces an existing tool that shares the same `name`. Since the doc comment states it is \"called once at module init\", a duplicate name almost certainly indicates a configuration error (two tools defined with the same name), and silently overwriting the earlier registration makes that error invisible — the first tool is dropped with no warning and only the last one ever runs. Consider throwing (or at least logging a warning) on duplicate names so misconfiguration is caught at startup rather than at runtime.","suggestion_code":" if (this.tools.some((t) => t.name === tool.name)) {\n throw new Error(`Agent tool already registered: ${tool.name}`);\n }\n this.tools.push(tool);","existing_code":" const idx = this.tools.findIndex((t) => t.name === tool.name);\n if (idx >= 0) {\n this.tools[idx] = tool;\n } else {\n this.tools.push(tool);\n }"}
{"path":"apps/server/src/agent-tools/agent-business-scope.factory.ts","start_line":22,"end_line":29,"category":"bug","severity":"medium","content":"Dead branch for non-super-admins: `mapPermissionCode`/`permissionToAction` in casl.constants.ts only ever produces create/read/update/delete domain rules — `manage` is granted solely by `createForUser` when `isSuperAdmin` is true. So for any regular user this method is effectively `isSuperAdmin || can(Update, Class)`. A user holding `attendance:edit` (mapped to `update Attendance`) or any `attendance:manage`-style exact code will be denied here, and `get-attendance-summary.tool.ts` will silently fall back to class-scoped data instead of all-attendance scope. If full attendance management should be honored, check `ability.can(CaslAction.Update, SubjectName.Attendance)` (or use `CaslAction.Access` + `permissionCodeSubject('attendance:edit')`); otherwise drop the misleading `Manage` branch.","suggestion_code":null,"existing_code":" canManageAllAttendance(context: AgentToolContext): boolean {\n const ability = this.ability(context);\n return (\n context.isSuperAdmin ||\n ability.can(CaslAction.Manage, SubjectName.Attendance) ||\n ability.can(CaslAction.Update, SubjectName.Class)\n );\n }"}
{"path":"apps/server/src/agent-tools/agent-business-scope.factory.ts","start_line":31,"end_line":38,"category":"bug","severity":"medium","content":"Same dead `Manage` branch: `dashboard:view` maps to `read Dashboard` (never `manage Dashboard`), so for non-super-admins `canManageAllDashboard` reduces to `isSuperAdmin || can(Update, Class)`. A user holding only `dashboard:view` (no `class:edit`) is treated as not managing all and `get-dashboard-stats.tool.ts` returns class-scoped data instead of school-wide stats. If `dashboard:view` should grant full scope, check `ability.can(CaslAction.Read, SubjectName.Dashboard)` or the exact permission code; otherwise remove the `Manage` branch.","suggestion_code":null,"existing_code":" canManageAllDashboard(context: AgentToolContext): boolean {\n const ability = this.ability(context);\n return (\n context.isSuperAdmin ||\n ability.can(CaslAction.Manage, SubjectName.Dashboard) ||\n ability.can(CaslAction.Update, SubjectName.Class)\n );\n }"}
{"path":"apps/server/src/agent-tools/agent-business-scope.factory.ts","start_line":17,"end_line":20,"category":"maintainability","severity":"low","content":"Duplicated logic: `context.isSuperAdmin || ability.can(CaslAction.Update, SubjectName.Class)` is repeated verbatim in all three methods, and each call rebuilds a fresh CASL ability via `createForUser`. Extract a shared private helper (e.g. `private canManageClasses(context: AgentToolContext): boolean`) and reuse it in the attendance/dashboard checks; this also makes the intended semantics (class-scope granting) explicit and avoids rebuilding the ability if several scope checks run in one tool invocation.","suggestion_code":null,"existing_code":" canManageAllClasses(context: AgentToolContext): boolean {\n const ability = this.ability(context);\n return context.isSuperAdmin || ability.can(CaslAction.Update, SubjectName.Class);\n }"}
{"path":"apps/server/src/agent-context/business-context.registry.ts","start_line":105,"end_line":107,"category":"bug","severity":"medium","content":"Attendance metadata is internally inconsistent: the description says records are by 班级与日期 (class + date), and the relations claim attendance links to class/schedule via 'class_schedule', but the entity has no classId field — only studentId/date/status. Since fields drive render_form, the agent cannot generate a class selector for attendance, and the class_schedule 'via' is the class↔schedule join table, not an attendance link. Suggest adding a classId field (and/or a dedicated via) so the form can capture the class the record belongs to.","suggestion_code":null,"existing_code":" field('studentId', '学生', 'number', { required: true }),\n field('date', '日期', 'date', { required: true }),\n field('status', '状态', 'enum', { enumFrom: 'attendance.status' }),"}
{"path":"apps/server/src/agent-context/business-context.registry.ts","start_line":220,"end_line":220,"category":"bug","severity":"medium","content":"This relation's requiredFor references stage 'profile', but the 'profile' stage only exists in the student_teaching and dormitory_billing workflows. The 'organization' entity is only used by the classroom_rental workflow, whose stages are classroom/organization/rental/contract/schedule — so 'profile' can never be satisfied in the workflow where this relation is actually relevant. Additionally, the counterpart relation is missing on the 'student' entity (student has relations to class/occupancy/bill/exam but not organization). Since validateRegistry checks requiredFor against the union of ALL workflow stage keys, this cross-workflow reference silently passes validation. This is likely a misplaced relation: it should either live on the student entity (e.g., relation('organization', 'organization', ['profile'])) or reference a stage within classroom_rental.","suggestion_code":null,"existing_code":" relation('student', 'organization', ['profile']),"}
{"path":"apps/server/src/agent-context/business-context.registry.ts","start_line":303,"end_line":303,"category":"maintainability","severity":"low","content":"Stage keys are not unique across workflows: 'profile' (student_teaching + dormitory_billing) and 'schedule' (student_teaching + classroom_rental) are duplicated. Because BusinessContextService.validateRegistry builds one global set from all workflows' stage keys, relation.requiredFor / nextSteps.after references are validated only against the union, so a relation in one workflow can silently reference a stage belonging to another workflow (see the organization entity's requiredFor ['profile']). Consider namespacing stage keys (e.g., 'teaching:profile', 'rental:schedule') or validating requiredFor against the workflow that actually uses the entity.","suggestion_code":null,"existing_code":" { key: 'schedule', label: '教室日程', entities: ['schedule', 'classroom', 'rental'] },"}
{"path":"apps/server/src/agent-context/pending-tasks.service.ts","start_line":110,"end_line":111,"category":"bug","severity":"medium","content":"The `LEFT JOIN bills b ON b.student_id = o.student_id` matches any bill the student has *ever* had, with no status or period filter. Since `bills` is generated per student per period (`period_start`/`period_end`) and includes historical statuses (`paid`, `cancelled`), a student whose current active occupancy simply has no bill *yet* for the current period will be excluded from this count as long as they have any older (even cancelled) bill. The '在住但未生成账单' count therefore only detects students with zero bills ever, which under-reports the intended pending task. Consider restricting to non-cancelled bills overlapping the occupancy's billing window, e.g. `AND b.status <> 'cancelled' AND b.period_end >= o.billing_start_date`.","suggestion_code":" LEFT JOIN bills b ON b.student_id = o.student_id\n AND b.status <> 'cancelled'\n AND b.period_end >= o.billing_start_date\n WHERE o.status = 'active'","existing_code":" LEFT JOIN bills b ON b.student_id = o.student_id\n WHERE o.status = 'active'"}
{"path":"apps/server/src/agent-context/pending-tasks.service.ts","start_line":161,"end_line":163,"category":"performance","severity":"low","content":"The five count queries are independent (each is a separate `dataSource.query`), but they run sequentially inside `await` in a `for...of` loop, so total latency is the sum of all queries. For an agent-facing service that may be called with several workflows, prefer filtering eligible definitions first and executing the queries in parallel with `Promise.all`, then mapping back in the original order.","suggestion_code":" const eligible = TASKS.filter(\n (d) => can(context, d.permission) && (!workflowKey || d.workflowKeys.includes(workflowKey)),\n );\n const tasks = await Promise.all(\n eligible.map(async (definition) => {\n if (definition.teacherRestricted && scope.type === 'teacher') {\n return {\n key: definition.key, label: definition.label, entity: definition.entity,\n permission: definition.permission, workflowKeys: definition.workflowKeys,\n count: null, restricted: true,\n detail: '当前角色数据范围不适合统计该待办,建议由教务/运营角色处理',\n } as PendingTask;\n }\n const { sql, params } = definition.sql(scope);\n const rows = await this.dataSource.query<Array<Record<string, unknown>>>(sql, params);\n return {\n key: definition.key, label: definition.label, entity: definition.entity,\n permission: definition.permission, workflowKeys: definition.workflowKeys,\n count: Number(rows[0]?.cnt ?? 0),\n };\n }),\n );\n return tasks;","existing_code":" for (const definition of TASKS) {\n if (!can(context, definition.permission)) continue;\n if (workflowKey && !definition.workflowKeys.includes(workflowKey)) continue;"}
{"path":"apps/server/src/agent-context/pending-tasks.service.ts","start_line":177,"end_line":179,"category":"bug","severity":"medium","content":"`getPendingTasks` has no error handling around the DB queries: if any single count query fails (missing table/column, timeout, transient connection error), the whole method rejects and the agent receives a raw DB error with no partial results or user-friendly message. Since this service aggregates independent pending-task counts for an agent, wrap each query in a per-task try/catch and either skip the entry or return `count: null` with a `detail` explaining the failure, so one failing task does not abort the entire list.","suggestion_code":" let count = 0;\n try {\n const { sql, params } = definition.sql(scope);\n const rows = await this.dataSource.query<Array<Record<string, unknown>>>(sql, params);\n count = Number(rows[0]?.cnt ?? 0);\n } catch (error) {\n // Log and return count:null with a friendly detail instead of failing the whole list\n }","existing_code":" const { sql, params } = definition.sql(scope);\n const rows = await this.dataSource.query<Array<Record<string, unknown>>>(sql, params);\n const count = Number(rows[0]?.cnt ?? 0);"}
{"path":"apps/server/src/agent-context/pending-tasks.service.ts","start_line":58,"end_line":60,"category":"maintainability","severity":"low","content":"`utcOffset(8)` is hardcoded in both `today()` and `inDays()`, which means the '七天内到期租赁' date window silently depends on a fixed China-time assumption. If the server is deployed in another region or the business timezone ever changes, the date boundaries shift. Consider injecting the timezone offset from configuration instead of hardcoding it.","suggestion_code":null,"existing_code":"function today(): string {\n return dayjs().utcOffset(8).format('YYYY-MM-DD');\n}"}
{"path":"apps/server/src/agent-tools/tools/get-dashboard-stats.tool.ts","start_line":9,"end_line":9,"category":"maintainability","severity":"low","content":"Multiple class property definitions are collapsed onto a single line, unlike sibling tools (e.g., get-attendance-summary.tool.ts) where each `readonly` property is on its own line. This hurts readability and makes diffs noisier. Consider splitting them one-per-line for consistency and maintainability.","suggestion_code":" readonly name = 'get_dashboard_stats';\n readonly skillKey = 'overview';\n readonly requiredPermission = 'dashboard:view';","existing_code":" readonly name = 'get_dashboard_stats'; readonly skillKey = 'overview'; readonly requiredPermission = 'dashboard:view';"}
{"path":"apps/server/src/agent-tools/tools/create-student.tool.ts","start_line":123,"end_line":129,"category":"bug","severity":"medium","content":"Type-confusion in organizationId validation: `Number(input.organizationId)` coerces values of other types, so booleans and single-element arrays slip through — e.g. `organizationId: true` becomes `1`, and `[5]` becomes `5`. Since the schema declares `type: 'integer'`, validation should require the raw value to actually be a number instead of coercing. Otherwise a malformed model payload can silently target a wrong organization.","suggestion_code":" if (input.organizationId !== undefined) {\n if (\n typeof input.organizationId !== 'number' ||\n !Number.isInteger(input.organizationId) ||\n input.organizationId <= 0\n ) {\n return { ok: false, error: 'organizationId 必须是正整数' };\n }\n result.organizationId = input.organizationId;\n }","existing_code":" if (input.organizationId !== undefined) {\n const id = Number(input.organizationId);\n if (!Number.isInteger(id) || id <= 0) {\n return { ok: false, error: 'organizationId 必须是正整数' };\n }\n result.organizationId = id;\n }"}
{"path":"apps/server/src/agent-tools/tools/create-student.tool.ts","start_line":134,"end_line":135,"category":"security","severity":"medium","content":"`execute` ignores the trusted `AgentToolContext` entirely and blindly trusts the model-supplied `organizationId` (falling back to the global host organization). `StudentsService.create` only asserts the target organization is active — it does not verify the current user has any access to it. Any user holding `student:create` can therefore create students in arbitrary active organizations (cross-tenant write), and there is no audit trail since `_context.userId` is unused. Consider restricting organizationId to the user's own accessible organizations (using the context) and/or recording the acting user for audit.","suggestion_code":null,"existing_code":" async execute(input: CreateStudentInput, _context: AgentToolContext): Promise<unknown> {\n const organizationId = input.organizationId ?? (await this.resolveDefaultOrganizationId());"}
{"path":"apps/server/src/agent-tools/tools/create-student.tool.ts","start_line":110,"end_line":113,"category":"bug","severity":"low","content":"Inconsistency: `name` is trimmed before validation, but `studentNo` and `idNumber` are not trimmed and whitespace-only strings are accepted. This allows storing blank/whitespace values (e.g. `' '`) that can later break lookups or duplicate checks. Trim them and reject empty results for consistency.","suggestion_code":" if (typeof input.studentNo !== 'string' || input.studentNo.trim().length > 30) {\n return { ok: false, error: 'studentNo 必须是长度不超过 30 的字符串' };\n }\n result.studentNo = input.studentNo.trim();","existing_code":" if (typeof input.studentNo !== 'string' || input.studentNo.length > 30) {\n return { ok: false, error: 'studentNo 必须是长度不超过 30 的字符串' };\n }\n result.studentNo = input.studentNo;"}
{"path":"apps/server/src/agent-tools/agent-tool.types.ts","start_line":31,"end_line":31,"category":"maintainability","severity":"medium","content":"The `_brand` field / `CONTEXT_BRAND` symbol is effectively dead and misleading code: nothing anywhere reads `_brand` — `assertTrusted` verifies trust solely via `trustedContexts` (WeakSet), so the brand adds no real forgery resistance despite the docs claiming the context is \"branded to prevent forgery\". Additionally, the class-field initializer `= CONTEXT_BRAND` can never run because instances are only created via `Object.create(AgentToolContext.prototype)` (the private constructor is never invoked), so the declaration implies a default that is never applied and contradicts the factory's `defineProperty`. Recommend removing `_brand`/`CONTEXT_BRAND` and relying on the WeakSet (which cannot be spoofed), or — if keeping the brand — actually verifying it inside `assertTrusted` instead of just setting it.","suggestion_code":null,"existing_code":" private readonly _brand = CONTEXT_BRAND;"}
{"path":"apps/server/src/agent-tools/agent-tool.types.ts","start_line":59,"end_line":59,"category":"bug","severity":"low","content":"`[...user.permissions]` will throw a raw `TypeError: user.permissions is not iterable` if the runtime user record lacks `permissions` (undefined). The `AuthenticatedUser` type guarantees the field, but this factory is the trust boundary between the JWT layer and the executor, so a defensive guard that fails fast with a clear message (and never produces a broken context) would be more robust and diagnosable.","suggestion_code":null,"existing_code":" value: Object.freeze([...user.permissions]),"}
{"path":"apps/server/src/agent-tools/agent-tools.module.ts","start_line":124,"end_line":127,"category":"maintainability","severity":"medium","content":"Tool registration is maintained in three separate places that must be kept in sync manually: the `imports`/`providers` array, the constructor parameter list, and these 20 `this.registry.register(...)` calls in `onModuleInit`. If a tool is added to `providers` but forgotten here (or vice versa), there is no compile-time error — the executor's registry lookup silently returns `undefined` and the tool is reported as \"unknown tool\" at runtime. Consider driving registration from a single source of truth, e.g. a module-level array of tool tokens used both in `providers` and iterated over in `onModuleInit` (or a custom provider factory that registers on instantiation), so adding a tool cannot be missed.","suggestion_code":null,"existing_code":" onModuleInit(): void {\n this.registry.register(this.businessContextTool);\n this.registry.register(this.entitySchemaTool);\n this.registry.register(this.pendingTasksTool);"}
{"path":"apps/server/src/agent-tools/tools/get-room-occupancy-summary.tool.ts","start_line":17,"end_line":17,"category":"bug","severity":"low","content":"Limit upper bound mismatch: this tool advertises `limit` up to 100 in both the schema and validator, but `RoomsService.agentGetRoomOccupancySummary` silently clamps results to max 50 (default 20). The LLM may request e.g. `limit=100` and receive only 50 rows without knowing results were truncated, which can skew its summarization. Align the maximum to 50 (as `search-rooms.tool.ts` does) so the advertised capability matches actual behavior.","suggestion_code":"const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;","existing_code":"const limit = optionalPositiveInt(raw.limit, 'limit', 100); if (!limit.ok) return limit;"}
{"path":"apps/server/src/agent-tools/agent-tool.executor.ts","start_line":123,"end_line":123,"category":"security","severity":"medium","content":"The tool lookup uses the raw, unsanitized `name` (model-controlled input), while `sanitizeName(name)` is only applied for audit logging. Today the registry only holds names matching the regex, so behavior is equivalent, but this creates a latent divergence: if a registered tool ever contains characters outside `[a-zA-Z0-9_]` (e.g. a hyphenated name), the audit log name and the actually-executed tool name will differ, and the sanitization step provides no defense-in-depth for the lookup itself. For consistency and hardening, perform the lookup with `safeName` so the sanitized name is the single source of truth for both the lookup and the audit/log output.","suggestion_code":"const tool = this.registry.getForExecution(safeName);","existing_code":"const tool = this.registry.getForExecution(name);"}
{"path":"apps/server/src/agent-tools/agent-tool.executor.ts","start_line":244,"end_line":246,"category":"maintainability","severity":"low","content":"`sanitizeName` can return an empty string when the input is empty (e.g. `name === ''`), producing malformed audit entries such as ` [denied]` in `action`. Add an empty-result fallback so the audit trail always contains a meaningful, sanitized tool name.","suggestion_code":" if (TOOL_NAME_RE.test(trimmed)) return trimmed;\n // Replace unsafe chars with underscore\n const replaced = trimmed.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, TOOL_NAME_MAX_LEN);\n return replaced || '_invalid';","existing_code":" if (TOOL_NAME_RE.test(trimmed)) return trimmed;\n // Replace unsafe chars with underscore\n return trimmed.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, TOOL_NAME_MAX_LEN);"}
{"path":"apps/server/src/agent-tools/agent-tool.executor.ts","start_line":128,"end_line":130,"category":"security","severity":"low","content":"The response for an unknown tool (`'未知工具'`) is distinct from the response for an existing-but-unauthorized tool (`'权限不足'`). Even though both use the `denied` status, the differing `error` text lets a probing caller (e.g. an LLM or an authenticated user without the permission) enumerate which tool names are registered vs. merely forbidden. If tool-name enumeration is a concern, use the same generic message for both branches (or remove the distinct `unknownTool` message).","suggestion_code":null,"existing_code":" undefined,\n SAFE_MESSAGES.unknownTool,\n context,"}
{"path":"apps/server/src/agent-tools/tools/get-student-basic.tool.ts","start_line":47,"end_line":51,"category":"maintainability","severity":"low","content":"The FORBIDDEN_INPUT_KEYS check is fully redundant: the second `allowedKeys` loop below already rejects any key not in `{'studentId'}` — which includes every entry in FORBIDDEN_INPUT_KEYS (and more). The first loop can never reject a payload the second one would accept, so this is duplicate/dead validation logic. Keep only one validation path (the `allowedKeys` whitelist) and remove FORBIDDEN_INPUT_KEYS to avoid confusion about which check is authoritative.","suggestion_code":" // Unexpected keys (including sensitive ones) are rejected by the allowedKeys whitelist below.","existing_code":" for (const key of Object.keys(input)) {\n if (FORBIDDEN_INPUT_KEYS.has(key)) {\n return { ok: false, error: `不允许的输入字段: ${key}` };\n }\n }"}
{"path":"apps/server/src/agent-tools/tools/get-student-basic.tool.ts","start_line":57,"end_line":60,"category":"bug","severity":"medium","content":"`Number(input.studentId)` silently coerces non-number types: e.g. `{\"studentId\": true}` becomes 1, `{\"studentId\": [\"1\"]}` or `{\"studentId\": \"1\"}` become 1. Booleans/arrays should be rejected outright rather than coerced, otherwise a malformed LLM payload can unexpectedly read a different student. Since the schema declares `type: 'integer'`, validate the raw type before conversion.","suggestion_code":" if (typeof input.studentId !== 'number' || !Number.isInteger(input.studentId) || input.studentId <= 0) {\n return { ok: false, error: 'studentId 必须是正整数' };\n }\n const studentId = input.studentId;","existing_code":" const studentId = Number(input.studentId);\n if (!Number.isInteger(studentId) || studentId <= 0) {\n return { ok: false, error: 'studentId 必须是正整数' };\n }"}
{"path":"apps/server/src/agent-tools/tools/get-attendance-summary.tool.ts","start_line":25,"end_line":25,"category":"bug","severity":"low","content":"The `limit` contract is inconsistent: the JSON schema advertises `maximum: 50` (hardcoded again here as the second source of `50`), but when the field is omitted `optionalPositiveInt` returns `undefined` and the service silently applies its own default of 30 (`query.limit ?? 30` in attendance.service.ts). Callers who omit `limit` therefore receive fewer rows than the tool's advertised maximum, and the two hardcoded `50`s can drift apart. Suggest declaring `default: 30` (or 50) in `inputSchema` and applying the default here in `validate` (e.g. `limit: limit.value ?? 50`) so the tool's contract is explicit and matches downstream behavior.","suggestion_code":null,"existing_code":"const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;"}
{"path":"apps/server/src/agent-tools/tools/search-bills.tool.ts","start_line":20,"end_line":20,"category":"maintainability","severity":"medium","content":"limit 的 schema 上限和校验上限都是 50但底层 agentSearchBills 在未传 limit 时默认只返回 20 条且没有分页/游标。LLM 不传 limit 时会把截断后的 20 条结果误认为全部匹配账单,导致后续判断/操作基于不完整数据。建议在 inputSchema 的 limit 属性中补充 description 说明默认值,或让未传时的默认行为与上限一致(例如默认也取 50。","suggestion_code":null,"existing_code":"const limit = optionalPositiveInt(raw.limit, 'limit', 50); if (!limit.ok) return limit;"}
{"path":"apps/server/src/agent-tools/tools/search-bills.tool.ts","start_line":19,"end_line":19,"category":"maintainability","severity":"low","content":"schema 中各属性keyword/status/periodStart/periodEnd都没有 descriptionLLM 只能根据字段名和一行 tool description 猜测参数含义status 仅按长度(20)校验,是自由字符串,模型猜错取值时会静默返回空结果。建议在 schema 中为 status 提供可选值说明(或确认 status 为自由字符串后接受此行为),并给其他属性补充简短描述以提升工具调用的准确率。","suggestion_code":null,"existing_code":"const status = optionalString(raw.status, 'status', 20); if (!status.ok) return status;"}
{"path":"apps/server/src/agent-tools/tools/search-classes.tool.ts","start_line":23,"end_line":23,"category":"bug","severity":"medium","content":"The `status` input is only checked for string length, but it is used to filter `class.status`, whose only valid values are the `ClassStatus` enum values (`enrolling` | `active` | `ended` | `suspended`, see `entities/class.entity.ts`). The inputSchema also gives the LLM no hint about allowed values (no enum/description), so the model can easily pass invalid values (e.g. `graduated`, `ENROLLING`) and silently get empty results instead of a clear validation error. Add an `enum` to the schema and validate the value against `ClassStatus` in `validate` (import it from `../../entities/class.entity`).","suggestion_code":"const status = optionalString(raw.status, 'status', 20); if (!status.ok) return status;\n if (status.value && !Object.values(ClassStatus).includes(status.value as ClassStatus)) {\n return { ok: false, error: 'status 必须是 enrolling/active/ended/suspended 之一' };\n }","existing_code":"const status = optionalString(raw.status, 'status', 20); if (!status.ok) return status;"}
{"path":"apps/server/src/agent-tools/tools/search-classroom-rentals.tool.ts","start_line":53,"end_line":53,"category":"maintainability","severity":"low","content":"The Boolean() coercion here is redundant: the type check above (`typeof raw.includeEnded !== 'boolean'` with early return) already guarantees that when `includeEnded` is present it is a boolean, so `Boolean(raw.includeEnded)` is an identity conversion. This can be simplified to just `raw.includeEnded` (TypeScript narrows it to `boolean | undefined` after the guard).","suggestion_code":" includeEnded: raw.includeEnded,","existing_code":" includeEnded: raw.includeEnded === undefined ? undefined : Boolean(raw.includeEnded),"}
{"path":"apps/server/src/agent-tools/tools/search-classroom-rentals.tool.ts","start_line":30,"end_line":30,"category":"maintainability","severity":"low","content":"The inputSchema declares `month` as a plain string with only a description, while `validate()` enforces the YYYY-MM format and a valid month range. Since the schema is likely consumed by agent/tooling to generate or pre-validate calls, it should declare the same constraint (e.g., `pattern: '^\\\\d{4}-\\\\d{2}$'`) so schema-level validation is consistent with the runtime validator.","suggestion_code":" month: { type: 'string', pattern: '^\\\\d{4}-\\\\d{2}$', description: 'YYYY-MM' },","existing_code":" month: { type: 'string', description: 'YYYY-MM' },"}
{"path":"apps/server/src/agent-tools/tools/search-rooms.tool.ts","start_line":10,"end_line":10,"category":"documentation","severity":"medium","content":"Description mismatch: this tool advertises '查询宿舍及床位占用数量' (returns room and bed occupancy counts), but the underlying agentSearchRooms (room-query.service.ts) only returns room metadata (id, roomNumber, building, floor, capacity, roomType, status) — no occupancy count fields at all. Occupancy numbers are provided by the separate GetRoomOccupancySummaryTool. Since the LLM agent relies on this description to decide which tool to call and what to expect, the misleading text will produce incorrect agent behavior. Suggest correcting it, e.g. '查询宿舍基本信息及状态,不返回住户资料和占用数。'.","suggestion_code":null,"existing_code":"readonly description = '查询宿舍及床位占用数量,不返回住户资料。';"}
{"path":"apps/server/src/agent-tools/tools/search-rooms.tool.ts","start_line":17,"end_line":17,"category":"maintainability","severity":"low","content":"The `status` parameter is accepted as an arbitrary string (maxLength 20) with no whitelist, yet the underlying rooms.status column is a free-form varchar (default 'available'). Any value outside the known set (e.g. 'occupied', 'archived') silently yields an empty result, and because valid statuses are not enumerated in the schema, the LLM agent has no way to know which values are usable. Consider constraining `status` to the known status values in inputSchema (and/or validating against them) so invalid input is rejected with a clear error instead of returning empty results.","suggestion_code":null,"existing_code":"const status = optionalString(raw.status, 'status', 20); if (!status.ok) return status;"}
{"path":"apps/server/src/ai-chat/ai-a2ui-submissions.service.ts","start_line":27,"end_line":29,"category":"bug","severity":"high","content":"TOCTOU race breaks the idempotency guarantee: `findSubmission` followed by `save` is not atomic. Under concurrent duplicate submissions (double-click / parallel retries with the same artifactId+clientRequestId), both requests can pass the existence check, and the second `save` will hit the unique index `uk_ai_a2ui_submissions_artifact_client`, throwing an unhandled duplicate-key error (QueryFailedError) and returning a 500 to the caller instead of the existing submission. Recommend an atomic upsert (e.g. `insert` with `ON DUPLICATE KEY`/`ON CONFLICT DO NOTHING` depending on DB) or catching the unique-violation error and re-reading the existing row in the conflict path.","suggestion_code":null,"existing_code":" const existing = await this.findSubmission(input.artifactId, input.clientRequestId);\n if (existing) return { created: false, submission: existing };\n const submission = await this.submissions.save("}
{"path":"apps/server/src/agent-tools/tools/search-expenses.tool.ts","start_line":37,"end_line":46,"category":"maintainability","severity":"low","content":"Minor contract inconsistency: the 3rd argument of `optionalPositiveInt` is the *maximum* (30), not a default. When the LLM omits `limit`, `limit.value` is `undefined` and is passed through unchanged, so the service silently falls back to its own default of 10 results — while the tool's `inputSchema` advertises a maximum of 30 with no mention of the effective default. The agent/model has no way to know results will be capped at 10 when `limit` is omitted. Suggest setting an explicit default in `validate` (e.g., `limit: limit.value ?? 10`) or documenting the default in the schema description so the tool's contract matches actual behavior.","suggestion_code":" const limit = optionalPositiveInt(raw.limit, 'limit', 30); if (!limit.ok) return limit;\n return {\n ok: true,\n value: {\n keyword: keyword.value,\n periodStart: periodStart.value,\n periodEnd: periodEnd.value,\n limit: limit.value ?? 10,\n },\n };","existing_code":" const limit = optionalPositiveInt(raw.limit, 'limit', 30); if (!limit.ok) return limit;\n return {\n ok: true,\n value: {\n keyword: keyword.value,\n periodStart: periodStart.value,\n periodEnd: periodEnd.value,\n limit: limit.value,\n },\n };"}
{"path":"apps/server/src/agent-tools/tools/search-exams.tool.ts","start_line":21,"end_line":24,"category":"maintainability","severity":"low","content":"The numeric constraints are hardcoded in two places that must stay in sync: the JSON schema exposed to the LLM (maxLength: 100/50, minimum: 1, maximum: 50) and the literal limits passed to optionalString/optionalPositiveInt in validate(). If one side is updated without the other (e.g. raising the schema maximum for `limit` without updating `optionalPositiveInt(raw.limit, 'limit', 50)`), input that is valid per the schema will be silently rejected at runtime. Suggest extracting module-level constants (e.g. `const LIMIT_MAX = 50`) and referencing them in both the schema and the validation calls.","suggestion_code":null,"existing_code":" limit: { type: 'integer', minimum: 1, maximum: 50 },\n },\n additionalProperties: false,\n };"}
{"path":"apps/server/src/agent-tools/tools/search-schedules.tool.ts","start_line":36,"end_line":40,"category":"maintainability","severity":"low","content":"The manual `weekDay` range check (`> 7`) duplicates functionality already provided by `optionalPositiveInt`'s `maximum` parameter (used for `limit`). It also makes the `!== undefined` guard unnecessary, since `undefined > 7` is `false` anyway. Simplify to `optionalPositiveInt(raw.weekDay, 'weekDay', 7)`, which produces the same behavior (rejects 0/negatives/8+) with a clearer error message and less code.","suggestion_code":" const weekDay = optionalPositiveInt(raw.weekDay, 'weekDay', 7);\n if (!weekDay.ok) return weekDay;","existing_code":" const weekDay = optionalPositiveInt(raw.weekDay, 'weekDay');\n if (!weekDay.ok) return weekDay;\n if (weekDay.value !== undefined && weekDay.value > 7) {\n return { ok: false, error: 'weekDay 必须在 1-7 之间' };\n }"}
{"path":"apps/server/src/agent-tools/tools/tool-input.ts","start_line":39,"end_line":40,"category":"bug","severity":"medium","content":"`Number(value)` performs very loose coercion on untrusted tool input: `true` → 1, `[5]` → 5, `\"1e2\"` → 100, `\"0x10\"` → 16 all pass as valid positive integers. Since these validators gate server-side tool execution, non-numeric types (booleans, arrays, objects, null) and non-canonical numeric strings should be rejected explicitly. Recommend a strict check, e.g. accept only `typeof value === 'number'` or a string matching `/^[1-9]\\d*$/` before converting.","suggestion_code":" const parsed =\n typeof value === 'number'\n ? value\n : typeof value === 'string' && /^[1-9]\\d*$/.test(value.trim())\n ? Number(value.trim())\n : NaN;\n if (!Number.isInteger(parsed) || parsed <= 0 || (maximum !== undefined && parsed > maximum)) {","existing_code":" const parsed = Number(value);\n if (!Number.isInteger(parsed) || parsed <= 0 || (maximum !== undefined && parsed > maximum)) {"}
{"path":"apps/server/src/agent-tools/tools/tool-input.ts","start_line":41,"end_line":41,"category":"bug","severity":"low","content":"The error message uses truthiness `maximum ? ...` while the validation above uses `maximum !== undefined`. If `maximum` is `0`, the constraint is applied in validation but silently omitted from the error message. Use `maximum !== undefined` for consistency.","suggestion_code":" return { ok: false, error: `${field} 必须是正整数${maximum !== undefined ? `且不超过${maximum}` : ''}` };","existing_code":" return { ok: false, error: `${field} 必须是正整数${maximum ? `且不超过${maximum}` : ''}` };"}
{"path":"apps/server/src/agent-tools/tools/tool-input.ts","start_line":27,"end_line":30,"category":"bug","severity":"low","content":"Length is checked on the raw value but the returned value is trimmed afterwards. This causes two inconsistencies: (1) a value like `\" ab \"` with maxLength 3 is rejected even though its trimmed form fits; (2) a whitespace-only string silently becomes `undefined` (treated as absent), which can mask a caller mistake. Trim first, then validate the trimmed length.","suggestion_code":" if (typeof value !== 'string') {\n return { ok: false, error: `${field} 必须是字符串` };\n }\n const trimmed = value.trim();\n if (trimmed.length > maxLength) {\n return { ok: false, error: `${field} 必须是长度不超过${maxLength}的字符串` };\n }\n return { ok: true, value: trimmed || undefined };","existing_code":" if (typeof value !== 'string' || value.length > maxLength) {\n return { ok: false, error: `${field} 必须是长度不超过${maxLength}的字符串` };\n }\n return { ok: true, value: value.trim() || undefined };"}
{"path":"apps/server/src/ai-chat/ai-a2ui.artifact.ts","start_line":21,"end_line":26,"category":"maintainability","severity":"medium","content":"The artifact type list is defined twice: once in the union type `A2uiArtifactType` and once in the `A2UI_ARTIFACT_TYPES` Set. This duplicates the source of truth and creates a drift risk — adding a type to the union but forgetting the Set makes `buildA2uiArtifact` throw at runtime, while adding to the Set only surfaces as call-site TS errors. Better to use a single const array and derive the union type from it, e.g.:\n\n```ts\nexport const A2UI_ARTIFACT_TYPES = ['form', 'review', 'chart', 'import_wizard'] as const;\nexport type A2uiArtifactType = (typeof A2UI_ARTIFACT_TYPES)[number];\n```\n\nand then `new Set<A2uiArtifactType>(A2UI_ARTIFACT_TYPES)` if the Set is still needed.","suggestion_code":null,"existing_code":"const A2UI_ARTIFACT_TYPES = new Set<A2uiArtifactType>([\n 'form',\n 'review',\n 'chart',\n 'import_wizard',\n]);"}
{"path":"apps/server/src/ai-chat/ai-a2ui.artifact.ts","start_line":42,"end_line":44,"category":"maintainability","severity":"low","content":"The runtime validation only covers `type`, not `status` (or `id`/`messageId`/`conversationId`). If this function can receive runtime data (e.g., from untyped sources), an invalid `status` would silently pass through and potentially break frontend normalization. For consistency, either validate `status` with its own allow-list (e.g., `new Set<A2uiArtifactStatus>(['rendering', 'pending', 'submitted', 'expired', 'cancelled'])`) or drop the `type` check if all callers are guaranteed type-safe at compile time.","suggestion_code":null,"existing_code":" if (!A2UI_ARTIFACT_TYPES.has(input.type)) {\n throw new Error(`未知 A2UI artifact 类型: ${String(input.type)}`);\n }"}
{"path":"apps/server/src/agent-tools/tools/update-students.tool.ts","start_line":226,"end_line":238,"category":"performance","severity":"medium","content":"Student updates are executed strictly sequentially with `await` inside a `for` loop. These operations are independent of each other, so they can run in parallel. Given the batch supports up to 12 items, consider mapping each item to a promise (with per-item try/catch to preserve the partial-failure behavior) and awaiting them with `Promise.all` to cut the total latency to roughly the slowest single update instead of the sum.","suggestion_code":" const results = await Promise.all(\n input.updates.map(async (item) => {\n const { id: _id, ...rest } = item;\n const dto = rest as UpdateStudentDto;\n try {\n const student = await this.studentsService.update(item.id, dto);\n return { id: item.id, name: student?.name ?? null };\n } catch (error) {\n return {\n id: item.id,\n error: error instanceof NotFoundException ? '学生不存在' : '更新失败',\n };\n }\n }),\n );\n for (const r of results) {\n if ('error' in r) failed.push(r);\n else updated.push(r);\n }","existing_code":" for (const item of input.updates) {\n const { id: _id, ...rest } = item;\n const dto = rest as UpdateStudentDto;\n try {\n const student = await this.studentsService.update(item.id, dto);\n updated.push({ id: item.id, name: student?.name ?? null });\n } catch (error) {\n failed.push({\n id: item.id,\n error: error instanceof NotFoundException ? '学生不存在' : '更新失败',\n });\n }\n }"}
{"path":"apps/server/src/agent-tools/tools/update-students.tool.ts","start_line":169,"end_line":170,"category":"bug","severity":"medium","content":"`Number(raw.id)`/`Number(raw.organizationId)` coerces non-number values: `Number(true)` is `1` and `Number(['5'])` is `5`, so a boolean `true` or a single-element array would pass the “must be a positive integer” check and update student #1. Since `validate()` is the defense-in-depth gate (the schema may not always run first), require the raw value to be a real number instead of coercing it.","suggestion_code":" if (typeof raw.id !== 'number' || !Number.isInteger(raw.id) || raw.id <= 0) {\n return { ok: false, error: `第 ${index + 1} 条的学生 id 必须是正整数` };\n }\n const id = raw.id;","existing_code":" const id = Number(raw.id);\n if (!Number.isInteger(id) || id <= 0) {"}
{"path":"apps/server/src/agent-tools/tools/update-students.tool.ts","start_line":199,"end_line":200,"category":"bug","severity":"low","content":"The same coercion issue applies to `organizationId`: `Number(true)` → `1`, `Number(['5'])` → `5`, so invalid payloads (booleans, arrays) pass the check. Require a real number type here as well.","suggestion_code":" if (typeof raw.organizationId !== 'number' || !Number.isInteger(raw.organizationId) || raw.organizationId <= 0) {\n return { ok: false, error: `第 ${index + 1} 条的 organizationId 必须是正整数` };\n }\n const organizationId = raw.organizationId;","existing_code":" const organizationId = Number(raw.organizationId);\n if (!Number.isInteger(organizationId) || organizationId <= 0) {"}
{"path":"apps/server/src/agent-tools/tools/update-students.tool.ts","start_line":227,"end_line":227,"category":"other","severity":"low","content":"`_id` is destructured but never used — dead variable. Also note that `dto` built from `rest` is passed directly to `repo.update` in `StudentsService.update` without going through the DTO's class-validator pipeline, so any flaw in this whitelist would hit the DB directly; at minimum remove the unused binding to avoid confusion about intent.","suggestion_code":" const rest = { ...item };\n delete rest.id;","existing_code":" const { id: _id, ...rest } = item;"}
{"path":"apps/server/src/agent-tools/tools/update-students.tool.ts","start_line":77,"end_line":78,"category":"bug","severity":"low","content":"`optionalString` accepts a trimmed empty string (`''`), and `validate()` requires only “at least one editable field”, so a payload like `{ id: 1, name: '' }` passes validation and will write an empty `name` into the database (bypassing DTO validation as noted). If empty values are meant to clear optional fields (e.g. `emergencyPhone`) that is fine, but for required fields like `name`/`studentNo`/`idNumber` empty strings should be rejected to avoid corrupting required data.","suggestion_code":" const trimmed = value.trim();\n if (trimmed.length === 0) return { ok: false, error: '字段不能为空字符串' };\n if (trimmed.length > max) return { ok: false, error: `字段长度不能超过 ${max}` };","existing_code":" const trimmed = value.trim();\n if (trimmed.length > max) return { ok: false, error: `字段长度不能超过 ${max}` };"}
{"path":"apps/server/src/agent-tools/tools/search-students.tool.ts","start_line":92,"end_line":98,"category":"bug","severity":"medium","content":"Loose type coercion in numeric validation: `Number(input.classId)` silently accepts values that are clearly not valid per the declared schema (`type: 'integer'`), e.g. `classId: true` → 1, `classId: [5]` → 5, `classId: \"5\"` / `\"0x10\"` → 16. The same pattern applies to `organizationId` and `limit`. This makes the runtime validation inconsistent with `inputSchema` and lets malformed model output through instead of failing validation. If numeric strings are intentionally tolerated, reject booleans/arrays explicitly; otherwise require a real number to match the schema.","suggestion_code":" if (input.classId !== undefined) {\n if (typeof input.classId !== 'number' || !Number.isInteger(input.classId) || input.classId <= 0) {\n return { ok: false, error: 'classId 必须是正整数' };\n }\n result.classId = input.classId;\n }","existing_code":" if (input.classId !== undefined) {\n const id = Number(input.classId);\n if (!Number.isInteger(id) || id <= 0) {\n return { ok: false, error: 'classId 必须是正整数' };\n }\n result.classId = id;\n }"}
{"path":"apps/server/src/agent-tools/tools/search-students.tool.ts","start_line":66,"end_line":74,"category":"maintainability","severity":"low","content":"This FORBIDDEN_INPUT_KEYS loop is fully redundant: the `allowedKeys` whitelist loop right below already rejects every key not in the whitelist (including all of these forbidden keys) with the exact same error message. Maintaining two sources of truth for input key validation risks drift (e.g., a key added to `allowedKeys` but forgotten here). Either remove this block and the constant, or give it a distinct error message so it adds value as an explicit denial.","suggestion_code":null,"existing_code":" // Reject forbidden keys\n for (const key of Object.keys(input)) {\n if (FORBIDDEN_INPUT_KEYS.has(key)) {\n return {\n ok: false,\n error: `不允许的输入字段: ${key}`,\n };\n }\n }"}
{"path":"apps/server/src/ai-chat/ai-chat.constants.ts","start_line":92,"end_line":94,"category":"maintainability","severity":"medium","content":"The description states fields are limited to 1-12, but the JSON schema has no minItems/maxItems, so providers validating against this schema will accept any number of fields. An LLM can emit dozens/hundreds of fields, inflating request payloads and downstream storage. Add minItems: 1 and maxItems: 12 to enforce the documented bound.","suggestion_code":" fields: {\n type: 'array',\n description: '表单字段1-12个',\n minItems: 1,\n maxItems: 12,","existing_code":" fields: {\n type: 'array',\n description: '表单字段1-12个',"}
{"path":"apps/server/src/ai-chat/ai-chat.constants.ts","start_line":104,"end_line":106,"category":"maintainability","severity":"low","content":"The description says options are limited to 1-20, but no minItems/maxItems is declared. Add minItems: 1 and maxItems: 20 so the schema matches the documented contract and bounds the payload.","suggestion_code":" options: {\n type: 'array',\n description: 'select 类型的选项1-20个',\n minItems: 1,\n maxItems: 20,","existing_code":" options: {\n type: 'array',\n description: 'select 类型的选项1-20个',"}
{"path":"apps/server/src/ai-chat/ai-chat.constants.ts","start_line":143,"end_line":145,"category":"maintainability","severity":"low","content":"The description says columns are 2-10, but the schema has no minItems/maxItems. Add minItems: 2 and maxItems: 10 to enforce the documented bound.","suggestion_code":" columns: {\n type: 'array',\n description: '列定义2-10个第一列为类别/名称,其余列为数值序列;饼图只用前两列(名称+数值)',\n minItems: 2,\n maxItems: 10,","existing_code":" columns: {\n type: 'array',\n description: '列定义2-10个第一列为类别/名称,其余列为数值序列;饼图只用前两列(名称+数值)',"}
{"path":"apps/server/src/ai-chat/ai-chat.constants.ts","start_line":156,"end_line":158,"category":"maintainability","severity":"medium","content":"The description limits rows to ≤500, but no maxItems is declared. render_chart rows are the largest possible payload of the A2UI tools (and are persisted/rendered per round); without maxItems: 500 the schema accepts arbitrarily large row arrays, creating a resource/cost risk. Add maxItems: 500 to enforce the documented cap.","suggestion_code":" rows: {\n type: 'array',\n description: '行数据≤500行键名须与 columns.key 对应)',\n maxItems: 500,","existing_code":" rows: {\n type: 'array',\n description: '行数据≤500行键名须与 columns.key 对应)',"}
{"path":"apps/server/src/ai-chat/ai-chat.helpers.ts","start_line":3,"end_line":4,"category":"security","severity":"medium","content":"Redaction ordering bug: the phone-number rule runs before the ID-card rule, and the phone pattern `1[3-9]\\d{9}` has no word boundaries. An 18-digit Chinese ID card containing an 11-digit mobile-like substring (e.g. `110101199003078510` contains `19900307851`) will have those 11 digits replaced first, so the subsequent `\\b\\d{17}[\\dXx]\\b` pattern can no longer match the full card and the remaining digits leak into logs — defeating the purpose of this redaction function for the most sensitive PII. Fix by redacting ID cards first and/or anchoring the phone pattern with `\\b` (which also avoids mangling longer numeric fields such as account numbers).","suggestion_code":" .replace(/\\b\\d{17}[\\dXx]\\b/g, '[ID_CARD]')\n .replace(/\\b1[3-9]\\d{9}\\b/g, '[PHONE]')","existing_code":" .replace(/1[3-9]\\d{9}/g, '[PHONE]')\n .replace(/\\b\\d{17}[\\dXx]\\b/g, '[ID_CARD]')"}
{"path":"apps/server/src/ai-chat/ai-chat.helpers.ts","start_line":11,"end_line":13,"category":"maintainability","severity":"low","content":"The sensitive-key regex in this replacer is unanchored substring matching, so any key merely containing one of these words gets its whole value replaced. For example `tokenCount`, `phoneVerified`, or `mobileVersion` — which are typically benign counters/booleans, not secrets — will be rendered as `\"[REDACTED]\"`, corrupting log output and metrics. Consider matching exact key names (case-insensitive) or a small allowlist of known sensitive keys instead.","suggestion_code":null,"existing_code":" if (/password|token|secret|api.?key|authorization|phone|mobile|id.?card|身份证/i.test(key)) {\n return '[REDACTED]';\n }"}
{"path":"apps/server/src/ai-chat/ai-chat.import-confirm.ts","start_line":45,"end_line":46,"category":"bug","severity":"medium","content":"Header 值在判空时用了 trim(),但实际写入 mapping 并参与 `allowedHeaders.has(headerName)` 严格比较时使用的是未 trim 的 `header.slice(0, 200)`。如果确认方返回的表头带首尾空格(如 `\" 姓名 \"`),即使它对应的正是工作表表头也会被误判为“不在工作表表头中”而抛错。建议先 trim 再截断比较,同时落库的 headerName 也更干净。","suggestion_code":" if (typeof header !== 'string' || !header.trim()) continue;\n const headerName = header.trim().slice(0, 200);","existing_code":" if (typeof header !== 'string' || !header.trim()) continue;\n const headerName = header.slice(0, 200);"}
{"path":"apps/server/src/ai-chat/ai-chat.import-confirm.ts","start_line":40,"end_line":41,"category":"maintainability","severity":"low","content":"`Object.entries` 返回的 key 永远是 string因此 `typeof field !== 'string'` 是恒为 false 的死分支。另外,`field.length > 50` 时直接静默 continue与“未知字段直接抛错”的校验风格不一致——用户误传一个超长字段名会被静默丢弃可能最终导致该阶段 mapping 为空),建议改为抛错或至少给出提示。","suggestion_code":" for (const [field, header] of Object.entries(fields as Record<string, unknown>)) {\n if (!field.trim() || field.length > 50) continue;","existing_code":" for (const [field, header] of Object.entries(fields as Record<string, unknown>)) {\n if (typeof field !== 'string' || !field.trim() || field.length > 50) continue;"}
{"path":"apps/server/src/ai-chat/ai-chat.import-confirm.ts","start_line":54,"end_line":54,"category":"bug","severity":"low","content":"当某个 step 的所有字段都被跳过(空 header / 超长字段等)时,仍会把空对象 `columnMapping = {}` 写入 `mapping[typedStepKey]`。下游若以“mapping 中是否包含该 stepKey”来判断该阶段是否已确认映射空对象会被误认为已确认。建议仅在 columnMapping 非空时才写入。","suggestion_code":" if (Object.keys(columnMapping).length > 0) mapping[typedStepKey] = columnMapping;","existing_code":" mapping[typedStepKey] = columnMapping;"}
{"path":"apps/server/src/ai-chat/ai-chat.import-confirm.ts","start_line":95,"end_line":97,"category":"bug","severity":"low","content":"`attachment.mimeType.includes(...)` 直接调用,若调用方传入的 attachment.mimeType 为 undefined/null例如来自部分解析的数据或 DB 可空列),会抛 TypeError 而不是返回 false。建议对 mimeType 做空值保护。","suggestion_code":" attachment.mimeType?.includes('spreadsheetml') ||\n attachment.mimeType?.includes('excel') ||\n attachment.mimeType?.includes('csv') ||","existing_code":" attachment.mimeType.includes('spreadsheetml') ||\n attachment.mimeType.includes('excel') ||\n attachment.mimeType.includes('csv') ||"}
{"path":"apps/server/src/ai-chat/ai-chat.controller.ts","start_line":320,"end_line":323,"category":"bug","severity":"medium","content":"If `execute` resolves successfully without ever calling `onReady` (headers never sent), the `finally` block only finalizes the response when `res.headersSent` is true. In the success-without-stream path nothing is written and the response is left dangling — the client hangs indefinitely with no data and no error, and the socket is only reclaimed by server timeouts. The rethrow path in `catch` only covers errors, not this case. Add a `started` flag set in `onReady` and after `await execute(...)` throw if the stream never started so the error propagates to the global exception filter (which will write a proper response since headers aren't sent yet).","suggestion_code":" try {\n await execute(abortController.signal, emit, onReady);\n if (!res.headersSent) {\n throw new Error('AI stream finished without starting the response');\n }\n } catch (error) {\n if (!res.headersSent) throw error;","existing_code":" try {\n await execute(abortController.signal, emit, onReady);\n } catch (error) {\n if (!res.headersSent) throw error;"}
{"path":"apps/server/src/ai-chat/ai-chat.controller.ts","start_line":130,"end_line":130,"category":"bug","severity":"medium","content":"`stream.pipe(res)` has no error handler on the read stream. If the underlying file is missing/corrupt (e.g. the file on disk was removed while the DB row still exists, or a storage failure occurs), `createReadStream` emits an async 'error' event which is not handled — the pipe stalls and the client hangs until a server-level timeout, with no error response. Attach a `stream.on('error', ...)` handler that logs and destroys/ends the response appropriately.","suggestion_code":" stream.on('error', () => {\n if (!res.headersSent) {\n res.status(500).end();\n } else {\n res.destroy();\n }\n });\n stream.pipe(res);","existing_code":" stream.pipe(res);"}
{"path":"apps/server/src/ai-chat/ai-chat.controller.ts","start_line":346,"end_line":346,"category":"security","severity":"low","content":"For HTTP 4xx/5xx upstream errors, `error.message` is forwarded verbatim to the SSE client. Upstream/AI service messages may contain internal implementation details (host paths, model names, stack traces, database errors) that should not be exposed to end users. Return a generic user-facing message and log the real error server-side instead.","suggestion_code":" if (status >= 500) return { code: 'UPSTREAM_ERROR', message: 'AI 服务暂时不可用' };","existing_code":" if (status >= 500) return { code: 'UPSTREAM_ERROR', message: error.message };"}
{"path":"apps/server/src/ai-chat/ai-chat.conversations.ts","start_line":58,"end_line":59,"category":"bug","severity":"medium","content":"TOCTOU race between the in-memory `activeConversations` check and the actual deletion. After the check there are several `await`s (attachment query, `conversations.remove`), during which a concurrently starting generation can call `acquireConversation` (which adds the id to the set synchronously) and begin writing messages to the conversation that is about to be deleted. This can result in FK errors or silently losing in-flight generation data. The same unsafe check-then-delete pattern exists in `deleteAllConversations` and `deleteMessage`. Consider re-verifying the set right before removal (and/or after the awaits) or guarding deletion with a DB-level lock/transaction.","suggestion_code":null,"existing_code":" if (context.activeConversations.has(id)) throw new ConflictException('该会话正在生成回答');\n const attachmentIds = await context.messages"}
{"path":"apps/server/src/ai-chat/ai-chat.conversations.ts","start_line":82,"end_line":89,"category":"maintainability","severity":"low","content":"The attachment-id collection query is duplicated three times (`deleteConversation`, `deleteAllConversations`, `deleteMessage`). Extract a shared helper such as `collectAttachmentIds(context, messageIds)` so that table names, the raw-to-number mapping and join logic stay in sync if they ever change.","suggestion_code":null,"existing_code":" const attachmentIds = await context.messages\n .createQueryBuilder('message')\n .innerJoin('message.attachments', 'attachment')\n .where('message.conversation_id IN (:...ids)', {\n ids: conversations.map((item) => item.id),\n })\n .select('attachment.id', 'id')\n .getRawMany<{ id: number }>();"}
{"path":"apps/server/src/ai-chat/ai-chat.conversations.ts","start_line":111,"end_line":112,"category":"bug","severity":"low","content":"`page` and `limit` are taken from user input without validation. `page = 0` (or negative) produces a negative `skip`, which TypeORM rejects or handles unpredictably (500 error), and a very large `limit` can cause an unbounded/heavy query. Clamp the values, e.g. `page = Math.max(1, page)` and `limit = Math.min(100, Math.max(1, limit))`.","suggestion_code":" skip: (Math.max(1, page) - 1) * Math.min(100, Math.max(1, limit)),\n take: Math.min(100, Math.max(1, limit)),","existing_code":" skip: (page - 1) * limit,\n take: limit,"}
{"path":"apps/server/src/ai-chat/ai-chart.service.ts","start_line":66,"end_line":68,"category":"bug","severity":"low","content":"When `rows` is missing or not an array (e.g. the model omits it or sends an object/string), this branch throws with the misleading message \"图表行数不能超过 500\", and an empty array `[]` is silently accepted (chart with no data). Suggest splitting the two cases: reject non-array/missing `rows` with a \"必须是数组\" message and reject only length overflow with the current message (same applies to the `columns` check above).","suggestion_code":null,"existing_code":" if (!Array.isArray(rawArgs.rows) || rawArgs.rows.length > MAX_ROWS) {\n throw new BadRequestException(`图表行数不能超过 ${MAX_ROWS}`);\n }"}
{"path":"apps/server/src/ai-chat/ai-chart.service.ts","start_line":78,"end_line":78,"category":"security","severity":"low","content":"The guard `isPlainRecord` only checks `typeof === 'object' && !Array.isArray`, so class instances, `Date`, `Map`, and objects created with a custom/null prototype also pass as \"plain records\". Since this input is untrusted AI-generated tool-arguments, the row/column validation here can be bypassed by non-plain objects (they are then silently treated as empty via `Object.entries`). Recommend a stricter check at the usage site, e.g. `Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null`.","suggestion_code":null,"existing_code":" if (!isPlainRecord(raw)) throw new BadRequestException(`图表第 ${index + 1} 行格式无效`);"}
{"path":"apps/server/src/ai-chat/ai-chart.service.ts","start_line":44,"end_line":46,"category":"bug","severity":"medium","content":"Only the scatter column count is special-cased. Other chart types pass through with arbitrary column counts and arbitrary cell values: e.g. a `pie`/`gauge` with 3+ columns, or a `line`/`bar`/`scatter` whose value columns contain strings/booleans/null, will pass validation and can break the frontend ECharts conversion this service is meant to protect. Consider per-chart-type shape checks (e.g. pie/gauge exactly 2 columns) and enforcing that value columns are numeric for numeric chart types.","suggestion_code":null,"existing_code":" if (rawArgs.chartType === 'scatter' && rawArgs.columns.length < 3) {\n throw new BadRequestException('散点图需要 3 列名称、X 数值、Y 数值');\n }"}
{"path":"apps/server/src/ai-chat/ai-attachment.service.ts","start_line":133,"end_line":133,"category":"maintainability","severity":"low","content":"`Repository.findByIds()` is deprecated in TypeORM 0.3.x and will be removed. Use the explicit query form with `In(uniqueIds)` instead so the query also filters by userId in one round trip.","suggestion_code":" const attachments = await this.attachments.find({ where: { id: In(uniqueIds) } });","existing_code":" const attachments = await this.attachments.findByIds(uniqueIds);"}
{"path":"apps/server/src/ai-chat/ai-attachment.service.ts","start_line":111,"end_line":115,"category":"performance","severity":"low","content":"The per-attachment DB removal and file unlink are independent operations; running them sequentially in a loop serializes I/O. Since the operations don't depend on each other's results, use Promise.all to process them in parallel (still skipping attachments bound to messages).","suggestion_code":" await Promise.all(\n attachments\n .filter((attachment) => !attachment.messages?.length)\n .map(async (attachment) => {\n await this.attachments.remove(attachment);\n await unlink(this.resolveStoragePath(attachment.storageKey)).catch(() => undefined);\n }),\n );","existing_code":" for (const attachment of attachments) {\n if (attachment.messages?.length) continue;\n await this.attachments.remove(attachment);\n await unlink(this.resolveStoragePath(attachment.storageKey)).catch(() => undefined);\n }"}
{"path":"apps/server/src/ai-chat/ai-attachment.service.ts","start_line":49,"end_line":49,"category":"maintainability","severity":"low","content":"The 10MB limit is defined here as MAX_FILE_BYTES but the enforcement at the transport layer (the controller's `FileInterceptor` `limits.fileSize`) is a separate hardcoded `10 * 1024 * 1024`. These two values can drift over time, causing the in-memory buffer check to disagree with what multer actually accepts. Export a shared constant (e.g. from a common module) and reference it in both places.","suggestion_code":null,"existing_code":" if (file.size > MAX_FILE_BYTES) throw new BadRequestException('单个附件不能超过 10MB');"}
{"path":"apps/server/src/ai-chat/ai-attachment.service.ts","start_line":181,"end_line":181,"category":"maintainability","severity":"low","content":"The attachment URL is hardcoded here. If the route in the controller ever changes (prefix, version, module path), this serialized payload silently points clients at a 404. Derive the path from the controller route or a shared route constant instead of duplicating the string.","suggestion_code":null,"existing_code":" url: `/api/ai/chat/attachments/${attachment.id}`,"}
{"path":"apps/server/src/ai-chat/ai-attachment.service.ts","start_line":89,"end_line":94,"category":"maintainability","severity":"low","content":"If this final `save` throws (e.g. transient DB failure), the file already written to disk is never cleaned up (the try/catch + unlink only guards the first save) and the DB record stays in 'processing' status, producing a permanent orphan until something explicitly removes it. Consider wrapping the post-extraction save so a failure unlinks the file (and ideally deletes the stale record), matching the cleanup done for the first save.","suggestion_code":null,"existing_code":" } catch {\n entity.processingStatus = 'failed';\n entity.processingError = '文件内容解析失败';\n }\n entity = await this.attachments.save(entity);\n return entity;"}
{"path":"apps/server/src/ai-chat/ai-chat.service-base.ts","start_line":85,"end_line":89,"category":"maintainability","severity":"medium","content":"`attachments: any[]` uses `any` without justification, which violates the project rule to avoid `any` and also diverges from the typed contract: `AiChatServiceContext` in `ai-chat.types.ts` and the concrete override in `ai-chat.service.ts` both declare `attachments: AiAttachment[]`. Using `any` here silently disables type checking for all callers/subclasses of this abstract method. Suggest typing it as `AiAttachment[]` (imported from `./entities`).","suggestion_code":" abstract buildUserContent(\n text: string,\n attachments: AiAttachment[],\n supportsVision: boolean,\n ): Promise<string | ModelContentPart[]>;","existing_code":" abstract buildUserContent(\n text: string,\n attachments: any[],\n supportsVision: boolean,\n ): Promise<string | ModelContentPart[]>;"}
{"path":"apps/server/src/ai-chat/ai-chat.generation.ts","start_line":85,"end_line":89,"category":"maintainability","severity":"low","content":"Nested ternary expression is used to pick `modelFocusContent`. The review rules prohibit nested ternaries as they hurt readability. Consider extracting this into a small helper function with early returns, e.g. `if (formSubmit) return buildFormSubmitModelContent(formSubmit); if (reviewSubmit) return buildReviewSubmitModelContent(reviewSubmit); return focusContent;`.","suggestion_code":null,"existing_code":" const modelFocusContent = formSubmit\n ? buildFormSubmitModelContent(formSubmit)\n : reviewSubmit\n ? buildReviewSubmitModelContent(reviewSubmit)\n : focusContent;"}
{"path":"apps/server/src/ai-chat/ai-chat.generation.ts","start_line":64,"end_line":78,"category":"maintainability","severity":"low","content":"The tool-name allowlist/denylist is duplicated across two `filter` blocks and relies on hardcoded string literals (`create_student`, `update_students`, `render_form`, `start_import_wizard`). These tool names are business-critical; if a tool is renamed the filter silently stops working. Extract the excluded tool names into a shared constant and consolidate the two filters into one (e.g. a single helper that computes the excluded set based on `formSubmit`/`reviewSubmit`).","suggestion_code":null,"existing_code":" if (!formSubmit && !reviewSubmit) {\n tools = tools.filter(\n (tool) =>\n tool.function.name !== 'create_student' && tool.function.name !== 'update_students',\n );\n }\n if (reviewSubmit) {\n tools = tools.filter(\n (tool) =>\n tool.function.name !== 'create_student' &&\n tool.function.name !== 'update_students' &&\n tool.function.name !== 'render_form' &&\n tool.function.name !== 'start_import_wizard',\n );\n }"}
{"path":"apps/server/src/ai-chat/ai-chat.generation.ts","start_line":151,"end_line":152,"category":"performance","severity":"low","content":"Tool calls within a round are awaited sequentially even though they are independent (each call is executed based on its own parsed arguments). In a loop that can run up to `MAX_TOOL_ROUNDS` times, this serializes potentially slow tool executions and adds latency per round. Consider `Promise.all` for parallel execution (tool run records are later re-sorted by `id`, so persistence order stays deterministic); only keep sequential execution if the ordering of the per-tool SSE events (`tool.completed`/`tool.failed`) must be guaranteed.","suggestion_code":null,"existing_code":" for (const call of toolCalls) {\n const toolResult = await executeTool("}
{"path":"apps/server/src/ai-chat/ai-chat.service.ts","start_line":110,"end_line":116,"category":"maintainability","severity":"low","content":"`summarize` and `safeStructured` duplicate the same `JSON.stringify(value, this.redactingReplacer)` + try/catch pattern. Both also swallow the serialization error silently (returning a sentinel value without any log), which makes failures such as circular references or BigInt values (`TypeError: Do not know how to serialize a BigInt`) impossible to diagnose in production. Consider extracting a shared private helper (e.g. `stringifySafely`) and logging the caught error before returning the fallback.","suggestion_code":null,"existing_code":" let json: string;\n try {\n json = JSON.stringify(value, this.redactingReplacer);\n } catch {\n return '[无法序列化]';\n }\n return this.redactText(json).slice(0, 2000);"}
{"path":"apps/server/src/ai-chat/ai-chat.service.ts","start_line":116,"end_line":116,"category":"bug","severity":"low","content":"`slice(0, 2000)` truncates the serialized string by UTF-16 code units, which can split a surrogate pair in the middle and leave an unpaired/invalid character in the summary. Since the same pattern (`safeStructured`) round-trips through `JSON.parse`, a truncated `summarize` output is also not guaranteed to be valid JSON if ever re-parsed. For a code-point-safe truncation, consider slicing with `Array.from(json)` (or `Intl.Segmenter`) instead.","suggestion_code":null,"existing_code":" return this.redactText(json).slice(0, 2000);"}
{"path":"apps/server/src/ai-chat/ai-chat.submissions.runtime.ts","start_line":19,"end_line":19,"category":"maintainability","severity":"medium","content":"`attachments` is typed as `any[]`, violating the project rule against the `any` type without justification. The entity relation is typed `AiAttachment[]` (ManyToMany), so passing `any[]` here bypasses type safety and hides invalid attachment shapes at the call sites. Import `AiAttachment` from `./entities` and use `AiAttachment[]`.","suggestion_code":" attachments?: AiAttachment[],","existing_code":" attachments?: any[],"}
{"path":"apps/server/src/ai-chat/ai-chat.submissions.runtime.ts","start_line":32,"end_line":32,"category":"bug","severity":"low","content":"The spread `{ clientRequestId, skillKey, ...metadata }` puts `metadata` last, so any caller-supplied metadata containing `clientRequestId` or `skillKey` silently overrides the explicitly passed parameters. This can make the persisted user-message metadata diverge from the assistant message's metadata (which is stored as `{ clientRequestId, skillKey }`), causing mismatched/duplicated request identifiers. Spread `metadata` first so the explicit arguments take precedence.","suggestion_code":" metadata: { ...metadata, clientRequestId, skillKey },","existing_code":" metadata: { clientRequestId, skillKey, ...metadata },"}
{"path":"apps/server/src/ai-chat/ai-chat.submissions.ts","start_line":3,"end_line":10,"category":"maintainability","severity":"low","content":"Duplicate logic: `resolveFormConversationId` and `resolveReviewConversationId` are structurally identical (`await service.findOwnedPending(id, userId)` then return `.conversationId`). The same `findOwnedPending` + `.conversationId` lookup is also repeated inline in `submitForm`/`submitReview` in `ai-chat.submissions.flow.ts`. Consider extracting a small shared helper (e.g. `resolveOwnedConversationId(find: () => Promise<{ conversationId: number }>)`) so the lookup/ownership semantics stay consistent in one place.","suggestion_code":"async function resolveOwnedConversationId(\n find: () => Promise<{ conversationId: number }>,\n): Promise<number> {\n const owned = await find();\n return owned.conversationId;\n}\n\nexport async function resolveFormConversationId(\n context: AiChatServiceContext,\n userId: number,\n formId: string,\n): Promise<number> {\n return resolveOwnedConversationId(() =>\n context.formService.findOwnedPending(formId, userId),\n );\n}","existing_code":"export async function resolveFormConversationId(\n context: AiChatServiceContext,\n userId: number,\n formId: string,\n): Promise<number> {\n const form = await context.formService.findOwnedPending(formId, userId);\n return form.conversationId;\n}"}
{"path":"apps/server/src/ai-chat/ai-chat.review-confirm.ts","start_line":115,"end_line":115,"category":"bug","severity":"medium","content":"`confirmReviewGroup` reports success and writes a hardcoded `status: 'success'` op-log entry even when no section was actually imported. The underlying `submitGroup` deliberately swallows per-section errors (persistent failures) and returns the review unchanged when every section in the group is already submitted or all submissions failed. The handler never verifies that at least one section transitioned to `submitted`, so the API returns 200 and logs \"成功\" for a failed/no-op confirm. Verify the resulting group sections before returning success (e.g., throw a ConflictException when none were newly confirmed), and derive the log status from the actual outcome.","suggestion_code":" const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type);\n const groupSections = context.reviewService\n .parseSections(updated.sectionsJson)\n .filter((section) => section.type === type);\n if (groupSections.length > 0 && groupSections.every((section) => section.status !== 'submitted')) {\n throw new ConflictException(`分组「${type}」没有可确认的分表,请查看分表失败原因`);\n }","existing_code":" const { review: updated } = await context.reviewService.submitGroup(review.id, user.id, type);"}
{"path":"apps/server/src/ai-chat/ai-chat.review-confirm.ts","start_line":23,"end_line":32,"category":"bug","severity":"medium","content":"`finalizeReview` runs after the import transaction has already committed, but it performs best-effort side effects (`markReviewSubmittedOnMessage` — a message findOne+save — plus `serialize`) that can still throw. If any of these fail, the request returns an error for an import that actually succeeded, and the client's retry then hits `分表已确认导入` (409) — a confusing and inconsistent user experience. The same applies to `logImportOp` failures in both handlers. Wrap the post-commit message-metadata sync and op-log write in try/catch so a best-effort sync failure cannot misreport a committed import as failed.","suggestion_code":"async function finalizeReview(\n context: AiChatServiceContext,\n updated: AiReview,\n): Promise<Record<string, unknown>> {\n try {\n await context.markReviewSubmittedOnMessage(\n updated.assistantMessageId,\n updated.conversationId,\n updated,\n );\n } catch {\n // 消息元数据同步为尽力而为,导入已提交,不影响返回结果\n }\n return context.reviewService.serialize(updated);","existing_code":"async function finalizeReview(\n context: AiChatServiceContext,\n updated: AiReview,\n): Promise<Record<string, unknown>> {\n await context.markReviewSubmittedOnMessage(\n updated.assistantMessageId,\n updated.conversationId,\n updated,\n );\n return context.reviewService.serialize(updated);"}
{"path":"apps/server/src/ai-chat/ai-chat.review-confirm.ts","start_line":59,"end_line":64,"category":"maintainability","severity":"low","content":"The `sectionPermission` map is rebuilt on every invocation of `assertReviewImportPermissions`, even though it is constant. Hoist it to module scope so it is allocated once.","suggestion_code":"const SECTION_PERMISSIONS: Record<AiReviewSectionType, string> = {\n students: 'student:create',\n rooms: 'room:create',\n transfers: 'occupancy:transfer',\n checkins: 'occupancy:checkin',\n};","existing_code":" const sectionPermission: Record<AiReviewSectionType, string> = {\n students: 'student:create',\n rooms: 'room:create',\n transfers: 'occupancy:transfer',\n checkins: 'occupancy:checkin',\n };"}
{"path":"apps/server/src/ai-chat/ai-chat.review-confirm.ts","start_line":14,"end_line":19,"category":"maintainability","severity":"low","content":"The `submitted`/`expired` status checks here are duplicated inside `submitSection`/`submitGroup` (where they are re-evaluated inside a transaction). Because this pre-check is not atomic with the subsequent confirm, it can go stale under concurrent requests (e.g., a second request may pass the check and then receive the 409 from the transaction-level check). It only serves as early feedback, so keep it in sync with the service-layer checks to avoid error-message drift if statuses change.","suggestion_code":null,"existing_code":" if (review.status === 'submitted') {\n throw new ConflictException('导入已全部确认,无需重复确认');\n }\n if (review.status === 'expired') {\n throw new ConflictException('导入预览已失效,请重新生成预览');\n }"}
{"path":"apps/server/src/ai-chat/ai-chat.submissions.flow.ts","start_line":144,"end_line":150,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" sectionTypes.map((type) => {\n if (type === 'students') return 'profile';\n if (type === 'rooms') return 'room';\n return 'checkin';\n }),","existing_code":" sectionTypes.map((type) =>\n type === 'students'\n ? 'profile'\n : type === 'rooms'\n ? 'room'\n : 'checkin',\n ),"}
{"path":"apps/server/src/ai-chat/ai-chat.submissions.flow.ts","start_line":191,"end_line":193,"category":"maintainability","severity":"low","content":"`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`.","suggestion_code":null,"existing_code":" const serialized = context.reviewService.serialize(updatedReview);\n onReady();\n emit('ui.artifact', {"}
{"path":"apps/server/src/ai-chat/ai-chat.submissions.flow.ts","start_line":134,"end_line":137,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" const { review: updatedReview, result } = await context.reviewService.submitAll(\n review.id,\n user.id,\n );"}
{"path":"apps/server/src/ai-chat/ai-chat.submissions.flow.ts","start_line":105,"end_line":105,"category":"maintainability","severity":"medium","content":"`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).","suggestion_code":null,"existing_code":"export async function submitReview("}
{"path":"apps/server/src/ai-chat/ai-chat.submit-content.ts","start_line":49,"end_line":56,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"export async function markFormSubmittedOnMessage(\n context: AiChatServiceContext,\n assistantMessageId: number,\n conversationId: number,\n): Promise<void> {\n const assistant = await context.messages.findOne({\n where: { id: assistantMessageId, conversationId },\n });"}
{"path":"apps/server/src/ai-chat/ai-chat.submit-content.ts","start_line":108,"end_line":116,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"export async function markReviewSubmittedOnMessage(\n context: AiChatServiceContext,\n assistantMessageId: number,\n conversationId: number,\n review?: AiReview,\n): Promise<void> {\n const assistant = await context.messages.findOne({\n where: { id: assistantMessageId, conversationId },\n });"}
{"path":"apps/server/src/ai-chat/ai-chat.submit-content.ts","start_line":46,"end_line":46,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" return `【表单提交:${submit.title}】${submissionId}\\n提交值JSON${json.slice(0, 32 * 1024)}${fieldErrors}\\n用户已在表单中确认你可以执行允许的写操作工具。`;"}
{"path":"apps/server/src/ai-chat/ai-chat.streaming.ts","start_line":256,"end_line":263,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" await context.conversations.update(\n { id: conversationId, userId: user.id },\n {\n lastMessageAt: now,\n ...(conversation.title === oldTitleHint ? { title: context.titleFromMessage(content) } : {}),\n },\n );\n await context.attachmentService.removeOrphans(user.id, orphanAttachmentIds);"}
{"path":"apps/server/src/ai-chat/ai-chat.streaming.ts","start_line":295,"end_line":295,"category":"bug","severity":"medium","content":"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.","suggestion_code":" where: { conversationId, id: LessThanOrEqual(focusUserMessageId), status: 'completed' },\n relations: { attachments: true },\n order: { createdAt: 'DESC', id: 'DESC' },\n take: MAX_HISTORY_MESSAGES + 1,","existing_code":" take: MAX_HISTORY_MESSAGES + 1,"}
{"path":"apps/server/src/ai-chat/ai-chat.streaming.ts","start_line":337,"end_line":337,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" return [{ type: 'text', text: combinedText }, ...contentParts];"}
{"path":"apps/server/src/ai-chat/ai-chat.streaming.ts","start_line":63,"end_line":63,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" conversation.title === DEFAULT_TITLE ? context.titleFromMessage(dto.message.trim()) : undefined,","existing_code":" conversation.title === DEFAULT_TITLE ? context.titleFromMessage(dto.message) : undefined,"}
{"path":"apps/server/src/ai-chat/ai-review.enrich.ts","start_line":7,"end_line":9,"category":"bug","severity":"medium","content":"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`.","suggestion_code":" if (typeof raw === 'number') {\n return organizations.some((org) => org.id === raw) ? raw : null;\n }\n const text = typeof raw === 'string' ? raw.trim() : '';\n if (/^\\d+$/.test(text)) {\n const id = Number(text);\n return organizations.some((org) => org.id === id) ? id : null;\n }","existing_code":" if (typeof raw === 'number') {\n return organizations.some((org) => org.id === raw) ? raw : null;\n }"}
{"path":"apps/server/src/ai-chat/ai-review.enrich.ts","start_line":11,"end_line":13,"category":"bug","severity":"low","content":"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).","suggestion_code":" if (!text) {\n return null;\n }","existing_code":" if (!text) {\n return organizations.find((org) => org.isHost)?.id ?? null;\n }"}
{"path":"apps/server/src/ai-chat/ai-chat.types.ts","start_line":63,"end_line":70,"category":"maintainability","severity":"medium","content":"`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.","suggestion_code":null,"existing_code":" if (\n section.type === 'students' ||\n section.type === 'rooms' ||\n section.type === 'transfers' ||\n section.type === 'checkins'\n ) {\n return section.type;\n }"}
{"path":"apps/server/src/ai-chat/ai-chat.types.ts","start_line":2,"end_line":5,"category":"maintainability","severity":"low","content":"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).","suggestion_code":null,"existing_code":"import { DataSource, Repository } from 'typeorm';\nimport { AiConfigService } from '../ai-config/ai-config.service';\nimport { AgentToolExecutor } from '../agent-tools/agent-tool.executor';\nimport { AgentToolContextFactory } from '../agent-tools/agent-tool.types';"}
{"path":"apps/server/src/ai-chat/ai-chat.tool-actions.ts","start_line":84,"end_line":87,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" } catch {\n await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: '表单参数无效', error: '表单参数无效' }, emit);\n return JSON.stringify({ status: 'failed', error: '表单参数无效' });\n }"}
{"path":"apps/server/src/ai-chat/ai-chat.tool-actions.ts","start_line":136,"end_line":139,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" } catch {\n await finishToolRun(context, run, call, startedAt, { status: 'failed', summary: '图表参数无效', error: '图表参数无效' }, emit);\n return JSON.stringify({ status: 'failed', error: '图表参数无效' });\n }"}
{"path":"apps/server/src/ai-chat/ai-chat.tool-actions.ts","start_line":107,"end_line":111,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" let charts: unknown[];\n if (isUnknownArray(existingCharts)) {\n charts = [...existingCharts];\n } else if (existingCharts) {\n charts = [existingCharts];\n } else {\n charts = [];\n }","existing_code":" const charts = isUnknownArray(existingCharts)\n ? [...existingCharts]\n : existingCharts\n ? [existingCharts]\n : [];"}
{"path":"apps/server/src/ai-chat/ai-chat.tool-actions.ts","start_line":90,"end_line":95,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"export async function executeRenderChart(\n context: AiChatServiceContext,\n messageId: number,\n call: ModelToolCall,\n emit: AiSseEmitter,\n): Promise<string> {"}
{"path":"apps/server/src/ai-chat/ai-form.service.ts","start_line":147,"end_line":147,"category":"security","severity":"medium","content":"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.","suggestion_code":" if (Buffer.byteLength(serialized, 'utf8') > MAX_VALUES_BYTES) throw new BadRequestException('提交内容过长');","existing_code":" if (serialized.length > MAX_VALUES_BYTES) throw new BadRequestException('提交内容过长');"}
{"path":"apps/server/src/ai-chat/ai-form.service.ts","start_line":110,"end_line":115,"category":"bug","severity":"medium","content":"`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.","suggestion_code":" async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {\n const result = await this.forms.update(\n { id: form.id, status: 'pending' },\n {\n status: 'submitted',\n submittedValuesJson: JSON.stringify(values),\n submittedAt: new Date(),\n },\n );\n if (!result.affected) throw new BadRequestException('表单不存在、已提交或已失效');\n form.status = 'submitted';\n form.submittedValuesJson = JSON.stringify(values);\n form.submittedAt = new Date();\n return form;\n }","existing_code":" async markSubmitted(form: AiForm, values: Record<string, unknown>): Promise<AiForm> {\n form.status = 'submitted';\n form.submittedValuesJson = JSON.stringify(values);\n form.submittedAt = new Date();\n return this.forms.save(form);\n }"}
{"path":"apps/server/src/ai-chat/ai-form.service.ts","start_line":103,"end_line":106,"category":"bug","severity":"low","content":"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.","suggestion_code":" await this.forms.update({ id: In(ids), status: 'pending' }, { status: 'expired' });","existing_code":" const expired = pending.filter((form) => form.id !== exceptFormId);\n if (expired.length === 0) return [];\n const ids = expired.map((form) => form.id);\n await this.forms.update({ id: In(ids) }, { status: 'expired' });"}
{"path":"apps/server/src/ai-chat/ai-form.service.ts","start_line":276,"end_line":278,"category":"bug","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" if (field.type === 'date' && !DATE_RE.test(value)) {\n throw new BadRequestException(`「${field.label}」必须是 YYYY-MM-DD 格式`);\n }"}
{"path":"apps/server/src/ai-chat/ai-form.service.ts","start_line":131,"end_line":131,"category":"security","severity":"low","content":"`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.","suggestion_code":" const result: Record<string, unknown> = Object.create(null);","existing_code":" const result: Record<string, unknown> = {};"}
{"path":"apps/server/src/ai-chat/ai-excel-reader.service.ts","start_line":84,"end_line":84,"category":"bug","severity":"high","content":"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.","suggestion_code":" truncated: from + limit < sheet.rows.length,","existing_code":" truncated: slice.length < limit,"}
{"path":"apps/server/src/ai-chat/ai-excel-reader.service.ts","start_line":41,"end_line":42,"category":"performance","severity":"medium","content":"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`.","suggestion_code":null,"existing_code":" /** Sheet list + row counts + a short sample, small enough for prompts. */\n async overview(buffer: Buffer): Promise<{ sheets: ExcelSheetInfo[]; text: string }> {"}
{"path":"apps/server/src/ai-chat/ai-excel-reader.service.ts","start_line":29,"end_line":31,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" } catch {\n return this.loadWithFallback(buffer);\n }"}
{"path":"apps/server/src/ai-chat/ai-excel-reader.service.ts","start_line":70,"end_line":71,"category":"performance","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" const sheets = await this.loadSheets(buffer);\n const sheet = sheets.find((item) => item.name === sheetName) ?? sheets[0];"}
{"path":"apps/server/src/ai-chat/ai-chat.tool-actions.import.ts","start_line":164,"end_line":171,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" await finishToolRun(context, run, call, startedAt, {\n status: 'success',\n summary: `已生成导入向导:${detail.steps\n .filter((step) => step.status !== 'skipped')\n .map((step) => step.label)\n .join('、')}`,\n }, emit);\n emit('ui.import_wizard', { messageId, wizard });"}
{"path":"apps/server/src/ai-chat/ai-chat.tool-actions.import.ts","start_line":121,"end_line":122,"category":"bug","severity":"low","content":"`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`).","suggestion_code":null,"existing_code":" for (const stage of stages) {\n if (!IMPORT_STEP_KEYS.includes(stage.stepKey)) {"}
{"path":"apps/server/src/ai-chat/ai-chat.tool-actions.import.ts","start_line":82,"end_line":89,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" } catch (error) {\n const summary = error instanceof Error ? error.message.slice(0, 100) : `${toolName} 失败`;\n await finishToolRun(context, run, call, startedAt, {\n status: 'failed',\n summary,\n error: summary,\n }, emit);\n return JSON.stringify({ status: 'failed', error: run.resultSummary });"}
{"path":"apps/server/src/ai-chat/ai-review.shared.ts","start_line":0,"end_line":0,"category":"bug","severity":"high","content":"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.","suggestion_code":"export type AiReviewSectionResult =\n | { created: number; skipped: number; issues: string[] }\n | { completed: number; skipped: number; issues: string[] };","existing_code":"export type AiReviewSectionResult =\n | { created: number; skipped: number; issues: string[] }\n | { created: number; skipped: number; issues: string[] };"}
{"path":"apps/server/src/ai-chat/ai-review.shared.ts","start_line":47,"end_line":50,"category":"bug","severity":"medium","content":"`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.","suggestion_code":null,"existing_code":"export function toDateString(value: unknown): string | null {\n if (typeof value === 'string' && DATE_RE.test(value.trim())) return value.trim();\n return null;\n}"}
{"path":"apps/server/src/ai-chat/ai-review.shared.ts","start_line":103,"end_line":106,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" if (key === 'transfers') {\n return `成功换宿 ${(result as { completed: number }).completed} 条,跳过 ${result.skipped} 条`;\n }\n return `成功入住 ${(result as { completed: number }).completed} 条,跳过 ${result.skipped} 条`;"}
{"path":"apps/server/src/ai-chat/ai-review.service.ts","start_line":9,"end_line":12,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":"import type {\n AiReviewStepSubmitResult,\n AiReviewSubmitResult,\n} from './ai-review.shared';","existing_code":"import {\n AiReviewStepSubmitResult,\n AiReviewSubmitResult,\n} from './ai-review.shared';"}
{"path":"apps/server/src/ai-chat/ai-review.service.ts","start_line":40,"end_line":46,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" async findOwnedPending(reviewId: string, userId: number): Promise<AiReview> {\n const review = await this.reviews.findOne({\n where: { id: reviewId, userId, status: 'pending' },\n });\n if (!review) throw new NotFoundException('导入预览不存在、已确认或已失效');\n return review;\n }"}
{"path":"apps/server/src/ai-chat/ai-chat.tools.ts","start_line":84,"end_line":84,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" reviewSubmitted: boolean,"}
{"path":"apps/server/src/ai-chat/ai-chat.tools.ts","start_line":97,"end_line":97,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" if ((call.name === 'create_student' || call.name === 'update_students') && !allowWriteTools) {"}
{"path":"apps/server/src/ai-chat/ai-chat.tools.ts","start_line":108,"end_line":108,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" const result = await context.toolExecutor.execute(call.name, parsedArgs, agentContext, allowedSkillKey);"}
{"path":"apps/server/src/ai-chat/ai-chat.tools.ts","start_line":111,"end_line":111,"category":"maintainability","severity":"low","content":"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).","suggestion_code":null,"existing_code":" run.resultSummary = context.summarize(result.result ?? result.error ?? null);"}
{"path":"apps/server/src/ai-chat/ai-chat.tools.ts","start_line":36,"end_line":36,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" (context.safeStructured(parsedArgs) as Record<string, unknown> | null),"}
{"path":"apps/server/src/ai-chat/ai-chat.tools.ts","start_line":134,"end_line":134,"category":"performance","severity":"low","content":"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.","suggestion_code":null,"existing_code":" if (modelPayload.length <= 32 * 1024) return modelPayload;"}
{"path":"apps/server/src/ai-chat/ai-review.workbook.ts","start_line":9,"end_line":12,"category":"bug","severity":"medium","content":"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.","suggestion_code":" } catch {\n throw new BadRequestException('导入预览数据解析失败');\n }\n if (!Array.isArray(parsed)) {\n throw new BadRequestException('导入预览数据格式无效');\n }","existing_code":" } catch {\n return [];\n }\n if (!Array.isArray(parsed)) return [];"}
{"path":"apps/server/src/ai-chat/ai-review.workbook.ts","start_line":14,"end_line":16,"category":"bug","severity":"low","content":"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.","suggestion_code":" if (\n !isPlainRecord(item) ||\n typeof item.key !== 'string' ||\n !SECTION_KEY_RE.test(item.key)\n ) {\n throw new BadRequestException('导入预览分表格式无效');\n }","existing_code":" if (!isPlainRecord(item) || typeof item.key !== 'string') {\n throw new BadRequestException('导入预览分表格式无效');\n }"}
{"path":"apps/server/src/ai-chat/ai-review.workbook.ts","start_line":25,"end_line":27,"category":"bug","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" columns: Array.isArray(section.columns) ? section.columns : [],\n rows: Array.isArray(section.rows) ? section.rows : [],\n issues: Array.isArray(section.issues) ? section.issues : [],"}
{"path":"apps/server/src/ai-chat/ai-review.workbook.ts","start_line":13,"end_line":13,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" return (parsed as unknown[]).map((item) => {","existing_code":" return parsed.map((item) => {"}
{"path":"apps/server/src/ai-chat/ai-review.import-relations.ts","start_line":192,"end_line":202,"category":"bug","severity":"high","content":"同一学生在同一批次中,如果一行只填手机号、另一行只填学号(或一行同时填了学号+手机号),去重 key 和数据库查询都只按单一标识phone 优先)进行,第二条记录无法命中第一条已创建的学生,会重复创建 Student重复学号/手机号),破坏数据唯一性。建议去重 key 同时覆盖两个标识,且查询时 phone 查不到再按 studentNo 查。","suggestion_code":" const dedupeKey = `${phone ? `phone:${phone}` : ''}|${studentNo ? `no:${studentNo}` : ''}`;\n if (seen.has(dedupeKey)) {\n skipped += 1;\n issues.push(`学生「${name}」与同一批次中的其他行手机号/学号重复`);\n continue;\n }\n seen.add(dedupeKey);\n\n let student = phone\n ? await studentRepo.findOne({ where: { phone } })\n : null;\n if (!student && studentNo) {\n student = await studentRepo.findOne({ where: { studentNo } });\n }","existing_code":" const dedupeKey = phone ? `phone:${phone}` : `no:${studentNo}`;\n if (seen.has(dedupeKey)) {\n skipped += 1;\n issues.push(`学生「${name}」与同一批次中的其他行手机号/学号重复`);\n continue;\n }\n seen.add(dedupeKey);\n\n let student = phone\n ? await studentRepo.findOne({ where: { phone } })\n : await studentRepo.findOne({ where: { studentNo } });"}
{"path":"apps/server/src/ai-chat/ai-review.import-relations.ts","start_line":255,"end_line":256,"category":"bug","severity":"medium","content":"历史入住记录(含 checkOutDate缺少基本校验checkOutDate 可能早于 checkInDate也可能与学生在住checkOutDate 为空)的现有记录时间段重叠,直接写入会生成自相矛盾/重叠的入住记录。建议在写入前校验 checkOutDate > checkInDate并对与学生现有在住/历史记录的重叠做检查后跳过并记录问题。","suggestion_code":null,"existing_code":" const checkOutDate = toDateString(row.checkOutDate);\n const isHistoricalRecord = Boolean(checkOutDate);"}
{"path":"apps/server/src/ai-chat/ai-review.import-relations.ts","start_line":253,"end_line":253,"category":"bug","severity":"medium","content":"checkInDate 无效时(如格式错误)会被 ?? 静默降级为当天日期,而不是像 importTransfers 那样将无效日期作为问题行跳过并提示。这会让日期写错的记录被静默导入成“今天”,与换宿逻辑的校验行为不一致,容易掩盖脏数据。建议对无效 checkInDate 跳过并输出提示。","suggestion_code":" const checkInDate = toDateString(row.checkInDate) ?? dayjs().utcOffset(8).format('YYYY-MM-DD');\n if (!toDateString(row.checkInDate)) {\n skipped += 1;\n issues.push(`学生「${name}」的入住日期格式无效(应为 YYYY-MM-DD`);\n continue;\n }","existing_code":" const checkInDate = toDateString(row.checkInDate) ?? dayjs().utcOffset(8).format('YYYY-MM-DD');"}
{"path":"apps/server/src/ai-chat/ai-review.import-relations.ts","start_line":106,"end_line":106,"category":"bug","severity":"low","content":"用 `String(active.checkInDate)` 与 YYYY-MM-DD 字符串直接做字典序比较是脆弱的:一旦数据库驱动把 date 列水合为 Date 对象String(Date) 会变成 \"Wed Aug 05 2026 ...\" 这类格式,比较结果会静默出错。建议统一用 toDateString 归一化后再比较。","suggestion_code":" if (transferDate < (toDateString(active.checkInDate) ?? '')) {","existing_code":" if (transferDate < String(active.checkInDate)) {"}
{"path":"apps/server/src/ai-chat/ai-validation.ts","start_line":17,"end_line":20,"category":"bug","severity":"medium","content":"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 `''`):","suggestion_code":" if (typeof value !== 'string') {\n throw new BadRequestException(`${label}必须是字符串`);\n }\n const trimmed = value.trim();\n if (!trimmed) {\n if (optional) return '';\n throw new BadRequestException(`${label}不能为空`);\n }","existing_code":" if (typeof value !== 'string' || !value.trim()) {\n throw new BadRequestException(`${label}必须是字符串`);\n }\n const trimmed = value.trim();"}
{"path":"apps/server/src/ai-chat/ai-validation.ts","start_line":3,"end_line":5,"category":"bug","severity":"low","content":"`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:","suggestion_code":"export function isPlainRecord(value: unknown): value is Record<string, unknown> {\n if (!value || typeof value !== 'object' || Array.isArray(value)) return false;\n const proto = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n}","existing_code":"export function isPlainRecord(value: unknown): value is Record<string, unknown> {\n return Boolean(value) && typeof value === 'object' && !Array.isArray(value);\n}"}
{"path":"apps/server/src/ai-chat/dto/ai-chat.dto.ts","start_line":16,"end_line":16,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"import { REASONING_EFFORT_LEVELS } from '../../ai-config/dto/ai-config.dto';"}
{"path":"apps/server/src/ai-chat/dto/ai-chat.dto.ts","start_line":61,"end_line":62,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" @IsUUID()\n clientRequestId: string;"}
{"path":"apps/server/src/ai-chat/dto/ai-chat.dto.ts","start_line":96,"end_line":97,"category":"security","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" @IsObject()\n values: Record<string, unknown>;"}
{"path":"apps/server/src/ai-chat/dto/ai-chat.dto.ts","start_line":20,"end_line":22,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" @IsString()\n @MaxLength(100)\n title?: string;"}
{"path":"apps/server/src/ai-chat/ai-review.submit.ts","start_line":52,"end_line":55,"category":"bug","severity":"medium","content":"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`.","suggestion_code":" return await context.dataSource.transaction(async (manager) => {\n const review = await manager.findOne(AiReview, {\n where: { id: reviewId, userId },\n lock: { mode: 'pessimistic_write' },\n });","existing_code":" return await context.dataSource.transaction(async (manager) => {\n const review = await manager.findOne(AiReview, {\n where: { id: reviewId, userId },\n });"}
{"path":"apps/server/src/ai-chat/ai-review.submit.ts","start_line":82,"end_line":84,"category":"bug","severity":"medium","content":"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.","suggestion_code":" section.issues = mergeIssues(section.issues, result.issues);\n section.resultSummary = JSON.stringify({ ...result, issues: section.issues, message });\n section.submittedAt = new Date().toISOString();","existing_code":" section.resultSummary = JSON.stringify({ ...result, message });\n section.submittedAt = new Date().toISOString();\n section.issues = mergeIssues(section.issues, result.issues);"}
{"path":"apps/server/src/ai-chat/ai-review.submit.ts","start_line":235,"end_line":236,"category":"bug","severity":"low","content":"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.","suggestion_code":" const created = Number((value as { created?: number }).created) || 0;\n target[key].created += created;","existing_code":" const created = (value as { created: number }).created;\n target[key].created += created;"}
{"path":"apps/server/src/ai-chat/ai-review.submit.ts","start_line":314,"end_line":316,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" if (matches.length === 0) {\n return { step: -1, title: dependencyType };\n }"}
{"path":"apps/server/src/ai-chat/ai-review.import-basic.ts","start_line":120,"end_line":125,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" const existing = await roomRepo.findOne({ where: { roomNumber } });\n if (existing) {\n skipped += 1;\n issues.push(`宿舍「${roomNumber}」已存在,未重复创建`);\n continue;\n }"}
{"path":"apps/server/src/ai-chat/ai-review.import-basic.ts","start_line":57,"end_line":63,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" const existing =\n (phone\n ? await studentRepo.findOne({ where: { phone } })\n : null) ||\n (studentNo\n ? await studentRepo.findOne({ where: { studentNo } })\n : null);"}
{"path":"apps/server/src/ai-chat/ai-review.import-basic.ts","start_line":174,"end_line":176,"category":"bug","severity":"high","content":"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.","suggestion_code":"export function nextDay(date: string): string {\n const parsed = new Date(`${date}T00:00:00Z`);\n if (Number.isNaN(parsed.getTime())) return date;\n parsed.setUTCDate(parsed.getUTCDate() + 1);\n const year = parsed.getUTCFullYear();\n const month = String(parsed.getUTCMonth() + 1).padStart(2, '0');\n const day = String(parsed.getUTCDate()).padStart(2, '0');\n return `${year}-${month}-${day}`;\n}","existing_code":"export function nextDay(date: string): string {\n const parsed = new Date(`${date}T00:00:00+08:00`);\n parsed.setDate(parsed.getDate() + 1);"}
{"path":"apps/server/src/ai-chat/ai-review.import-basic.ts","start_line":25,"end_line":26,"category":"performance","severity":"medium","content":"Sequential per-row DB round trips: each row issues 12 `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`).","suggestion_code":null,"existing_code":" for (const row of section.rows) {\n const name = row.name === undefined || row.name === null ? '' : String(row.name).trim();"}
{"path":"apps/server/src/ai-chat/entities/ai-attachment.entity.ts","start_line":45,"end_line":46,"category":"bug","severity":"medium","content":"`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.","suggestion_code":"@Column({ name: 'extracted_text', type: 'longtext', nullable: true })\n extractedText: string | null;","existing_code":"@Column({ name: 'extracted_text', type: 'text', nullable: true })\n extractedText: string | null;"}
{"path":"apps/server/src/ai-chat/entities/ai-attachment.entity.ts","start_line":48,"end_line":49,"category":"maintainability","severity":"low","content":"`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`.","suggestion_code":"@Column({ name: 'processing_error', type: 'text', nullable: true })\n processingError: string | null;","existing_code":"@Column({ name: 'processing_error', type: 'varchar', length: 200, nullable: true })\n processingError: string | null;"}
{"path":"apps/server/src/ai-chat/entities/ai-attachment.entity.ts","start_line":36,"end_line":37,"category":"bug","severity":"low","content":"`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).","suggestion_code":"@Column({ type: 'bigint', transformer: { to: (v: number) => v, from: (v: string) => Number(v) } })\n size: number;","existing_code":"@Column({ type: 'integer' })\n size: number;"}
{"path":"apps/server/src/ai-chat/ai-model-stream.service.ts","start_line":253,"end_line":253,"category":"bug","severity":"medium","content":"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).","suggestion_code":" lookup(parsed.hostname, { all: true, family: 0 }, (dnsError, addresses) => {\n if (signal.aborted) return reject(signal.reason ?? new Error('aborted'));","existing_code":" lookup(parsed.hostname, { all: true, family: 0 }, (dnsError, addresses) => {"}
{"path":"apps/server/src/ai-chat/ai-model-stream.service.ts","start_line":254,"end_line":254,"category":"bug","severity":"low","content":"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.","suggestion_code":" if (dnsError || !addresses?.length) {\n const err = new Error('DNS 解析失败') as NodeJS.ErrnoException;\n err.code = (dnsError as NodeJS.ErrnoException | null)?.code ?? 'ENOTFOUND';\n return reject(err);\n }","existing_code":" if (dnsError || !addresses?.length) return reject(new Error('DNS 解析失败'));"}
{"path":"apps/server/src/ai-chat/ai-model-stream.service.ts","start_line":122,"end_line":122,"category":"bug","severity":"medium","content":"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.","suggestion_code":" response.body.resume();\n response.body.on('error', () => undefined);","existing_code":" response.body.resume?.();"}
{"path":"apps/server/src/ai-chat/ai-model-stream.service.ts","start_line":140,"end_line":140,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" if (response.status < 200 || response.status >= 300) {"}
{"path":"apps/server/src/ai-chat/ai-model-stream.service.ts","start_line":255,"end_line":257,"category":"security","severity":"low","content":"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.","suggestion_code":null,"existing_code":" const allowPrivate =\n process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true' ||\n DNS_TRUSTED_HOSTS.has(parsed.hostname);"}
{"path":"apps/server/src/ai-chat/entities/ai-form.entity.ts","start_line":65,"end_line":69,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" @Column({ type: 'varchar', length: 20, default: 'pending' })\n status: AiFormStatus;\n\n @Column({ name: 'submitted_values_json', type: 'text', nullable: true })\n submittedValuesJson: string | null;"}
{"path":"apps/server/src/ai-chat/entities/ai-form.entity.ts","start_line":59,"end_line":60,"category":"bug","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" @Column({ name: 'submit_label', type: 'varchar', length: 20, default: '提交' })\n submitLabel: string;"}
{"path":"apps/server/src/ai-chat/entities/ai-form.entity.ts","start_line":49,"end_line":51,"category":"maintainability","severity":"low","content":"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`.","suggestion_code":null,"existing_code":" @ManyToOne(() => AiMessage, { onDelete: 'CASCADE' })\n @JoinColumn({ name: 'assistant_message_id' })\n assistantMessage: AiMessage | null;"}
{"path":"apps/server/src/ai-chat/entities/ai-review.entity.ts","start_line":73,"end_line":74,"category":"maintainability","severity":"medium","content":"`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.","suggestion_code":null,"existing_code":"@Column({ name: 'sections_json', type: 'text' })\n sectionsJson: string;"}
{"path":"apps/server/src/ai-chat/entities/ai-review.entity.ts","start_line":63,"end_line":64,"category":"maintainability","severity":"low","content":"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.","suggestion_code":"@ManyToOne(() => AiMessage, { nullable: false, onDelete: 'CASCADE' })\n @JoinColumn({ name: 'assistant_message_id' })","existing_code":"@ManyToOne(() => AiMessage, { onDelete: 'CASCADE' })\n @JoinColumn({ name: 'assistant_message_id' })"}
{"path":"apps/server/src/ai-chat/entities/ai-review.entity.ts","start_line":54,"end_line":55,"category":"performance","severity":"low","content":"`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.","suggestion_code":null,"existing_code":"@Column({ name: 'conversation_id', type: 'integer' })\n conversationId: number;"}
{"path":"apps/server/src/ai-chat/entities/ai-tool-run.entity.ts","start_line":15,"end_line":15,"category":"maintainability","severity":"low","content":"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.","suggestion_code":"@Entity('ai_tool_runs')","existing_code":"@Index('idx_ai_tool_runs_message', ['messageId'])"}
{"path":"apps/server/src/ai-config/ai-config.entity.ts","start_line":33,"end_line":34,"category":"security","severity":"medium","content":"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.","suggestion_code":" @Column({ name: 'encrypted_api_key', type: 'text', nullable: true, select: false })\n encryptedApiKey: string | null;","existing_code":" @Column({ name: 'encrypted_api_key', type: 'text', nullable: true })\n encryptedApiKey: string | null;"}
{"path":"apps/server/src/ai-config/ai-config.entity.ts","start_line":27,"end_line":28,"category":"maintainability","severity":"low","content":"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.","suggestion_code":" @Column({ type: 'enum', enum: AiProvider, default: AiProvider.DEEPSEEK })\n provider: AiProvider;","existing_code":" @Column({ type: 'varchar', length: 50, default: AiProvider.DEEPSEEK })\n provider: AiProvider;"}
{"path":"apps/server/src/ai-config/ai-config.controller.ts","start_line":41,"end_line":44,"category":"bug","severity":"medium","content":"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.","suggestion_code":" const config = await this.service.saveConfig(body);\n try {\n let hostname = '-';\n try { hostname = new URL(config.baseUrl).hostname; } catch { /* keep '-' */ }\n await logAudit(this.opLog, req, {\n module: 'ai-config', action: 'save', targetId: config.id, targetType: 'AiConfig', detail: `provider=${body.provider} host=${hostname} model=${body.defaultModel ? 'configured' : 'not-set'}`,\n });\n } catch {\n // audit-log failure must not turn a successful save into an error response\n }","existing_code":" const config = await this.service.saveConfig(body);\n await logAudit(this.opLog, req, {\n 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'}`,\n });"}
{"path":"apps/server/src/ai-config/ai-config.controller.ts","start_line":68,"end_line":71,"category":"bug","severity":"medium","content":"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.","suggestion_code":" const data = await this.service.clearKey();\n try {\n await logAudit(this.opLog, req, {\n module: 'ai-config', action: 'clear-key', targetType: 'AiConfig', detail: `keySource=${data.keySource}`,\n });\n } catch {\n // audit-log failure must not turn a successful clear into an error response\n }","existing_code":" const data = await this.service.clearKey();\n await logAudit(this.opLog, req, {\n module: 'ai-config', action: 'clear-key', targetType: 'AiConfig', detail: `keySource=${data.keySource}`,\n });"}
{"path":"apps/server/src/ai-config/ai-config.controller.ts","start_line":51,"end_line":54,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" const result = await this.service.testConnection(body);\n await logAudit(this.opLog, req, {\n module: 'ai-config', action: 'test', targetType: 'AiConfig', detail: `provider=${body.provider ?? '-'} success=${result.success} latency=${result.latencyMs ?? '-'}`, status: result.success ? 'success' : 'failure',\n });"}
{"path":"apps/server/src/ai-config/ai-config.probe.ts","start_line":146,"end_line":152,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":" let message: string;\n if (modelAvailable) {\n message = `连接成功,目标模型 \"${effectiveDefaultModel}\" 可用`;\n } else if (effectiveDefaultModel) {\n message = '连接成功,但未找到目标模型';\n } else if (models.length > 0) {\n message = `连接成功,可用模型 ${models.length} 个`;\n } else {\n message = '连接成功,但未返回可用模型';\n }","existing_code":" const message = modelAvailable\n ? `连接成功,目标模型 \"${effectiveDefaultModel}\" 可用`\n : effectiveDefaultModel\n ? '连接成功,但未找到目标模型'\n : models.length > 0\n ? `连接成功,可用模型 ${models.length} 个`\n : '连接成功,但未返回可用模型';"}
{"path":"apps/server/src/ai-config/ai-config.probe.ts","start_line":164,"end_line":173,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":" let message = '连接失败,请检查 Base URL';\n if (err instanceof Error && err.message === '连接超时') {\n message = '连接超时';\n } else if (err instanceof Error && err.message === '响应过大') {\n message = '响应过大';\n }","existing_code":" const message =\n err instanceof Error\n ? err.message === '连接超时'\n ? '连接超时'\n : err.message === '响应过大'\n ? '响应过大'\n : err.message === '禁止重定向'\n ? '连接失败,请检查 Base URL'\n : '连接失败,请检查 Base URL'\n : '连接失败,请检查 Base URL';"}
{"path":"apps/server/src/ai-config/ai-config.probe.ts","start_line":203,"end_line":207,"category":"maintainability","severity":"medium","content":"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).","suggestion_code":null,"existing_code":" const message = err instanceof BadRequestException ? err.message : '请求参数无效';\n return { success: false, models: [], message };\n }\n\n // DNS SSRF check"}
{"path":"apps/server/src/ai-config/ai-config.probe.ts","start_line":126,"end_line":131,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" } catch {\n config.lastTestedAt = new Date();\n config.lastTestLatencyMs = latencyMs;\n config.verified = false;\n await context.save(config);\n return {"}
{"path":"apps/server/src/ai-config/ai-config.probe.ts","start_line":18,"end_line":21,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" resolveApiKey(config: AiConfig | null): {\n plaintext: string | null;\n source: 'database' | 'environment' | 'none';\n };"}
{"path":"apps/server/src/app.module.ts","start_line":168,"end_line":168,"category":"bug","severity":"high","content":"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'","suggestion_code":"synchronize: config.get('DB_SYNCHRONIZE', process.env.NODE_ENV === 'production' ? 'false' : 'true') === 'true',","existing_code":"synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',"}
{"path":"apps/server/src/app.module.ts","start_line":162,"end_line":162,"category":"bug","severity":"low","content":"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.","suggestion_code":"port: Number(config.get('DB_PORT', 3306)),","existing_code":"port: config.get<number>('DB_PORT', 3306),"}
{"path":"apps/server/src/app.module.ts","start_line":23,"end_line":23,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"const allMigrations = ["}
{"path":"apps/server/src/app.module.ts","start_line":89,"end_line":89,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":"limit: 100, // 普通接口每分钟100次"}
{"path":"apps/server/src/ai-chat/entities/ai-message.entity.ts","start_line":73,"end_line":74,"category":"bug","severity":"low","content":"`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.","suggestion_code":" @CreateDateColumn({ name: 'created_at', type: 'datetime(6)' })\n createdAt: Date;","existing_code":" @CreateDateColumn({ name: 'created_at', type: 'datetime' })\n createdAt: Date;"}
{"path":"apps/server/src/ai-chat/entities/ai-message.entity.ts","start_line":46,"end_line":47,"category":"bug","severity":"low","content":"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.","suggestion_code":" @Column({ type: 'varchar', length: 20, default: 'pending' })\n status: AiMessageStatus;","existing_code":" @Column({ type: 'varchar', length: 20, default: 'completed' })\n status: AiMessageStatus;"}
{"path":"apps/server/src/archive/archive-report.attendance.ts","start_line":118,"end_line":118,"category":"bug","severity":"high","content":"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.","suggestion_code":null,"existing_code":" const sessions = ['上午', '下午', '晚自习'];"}
{"path":"apps/server/src/archive/archive-report.attendance.ts","start_line":138,"end_line":138,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" <h3>考勤明细最近30条</h3>"}
{"path":"apps/server/src/archive/archive-report.attendance.ts","start_line":58,"end_line":59,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" const statuses = ['present', 'absent', 'late', 'leave'] as const;\n const counts = statuses.map((s) => records.filter((r) => r.status === s).length);"}
{"path":"apps/server/src/ai-config/dto/ai-config.dto.ts","start_line":64,"end_line":90,"category":"maintainability","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"export class TestAiConfigDto {\n @IsOptional()\n @IsIn(PROVIDERS)\n provider?: AiProvider;\n\n @IsOptional()\n @IsString()\n baseUrl?: string;\n\n @IsOptional()\n @IsString()\n apiKey?: string;\n\n @IsOptional()\n @IsString()\n defaultModel?: string;\n\n @IsOptional()\n @IsInt()\n @Min(1000)\n @Max(120000)\n timeoutMs?: number;\n\n @IsOptional()\n @IsIn(REASONING_EFFORT_LEVELS)\n reasoningEffort?: string | null;\n}"}
{"path":"apps/server/src/ai-config/dto/ai-config.dto.ts","start_line":58,"end_line":61,"category":"maintainability","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsIn(REASONING_EFFORT_LEVELS)\n reasoningEffort?: string | null;\n}"}
{"path":"apps/server/src/ai-config/ai-config.service.ts","start_line":190,"end_line":196,"category":"bug","severity":"medium","content":"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.","suggestion_code":" if (config.enabled) {\n const { plaintext } = this.resolveApiKey(config);\n if (!plaintext) throw new BadRequestException('启用 AI 服务前必须配置 API Key');\n if (!config.defaultModel?.trim()) {\n throw new BadRequestException('启用 AI 服务前必须配置默认模型');\n }\n }","existing_code":" if (dto.enabled === true) {\n const { plaintext } = this.resolveApiKey(config);\n if (!plaintext) throw new BadRequestException('启用 AI 服务前必须配置 API Key');\n if (!config.defaultModel?.trim()) {\n throw new BadRequestException('启用 AI 服务前必须配置默认模型');\n }\n }"}
{"path":"apps/server/src/ai-config/ai-config.service.ts","start_line":127,"end_line":131,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" maskedApiKey: config.keyLast4\n ? this.buildMaskedKey(config.keyLast4)\n : source !== 'none'\n ? '••••'\n : null,"}
{"path":"apps/server/src/ai-config/ai-config.service.ts","start_line":116,"end_line":117,"category":"bug","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" const config = await this.getOrCreateConfig();\n const { source } = this.resolveApiKey(config);"}
{"path":"apps/server/src/archive/archive-report.cover.ts","start_line":33,"end_line":33,"category":"security","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"<div class=\"cover-desc\">学号: ${esc(student.studentNo || '-')}<br>身份证号: ${esc(student.idNumber || '-')}</div>"}
{"path":"apps/server/src/archive/archive-report.learning.ts","start_line":52,"end_line":53,"category":"bug","severity":"medium","content":"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 ?? '')) || '-'`).","suggestion_code":" <div class=\"summary-row\"><span>文化课成绩</span><span><strong>${esc(String(result.cultureFinalScore ?? '')) || '-'}</strong></span></div>\n <div class=\"summary-row\"><span>专业课成绩</span><span><strong>${esc(String(result.professionalFinalScore ?? '')) || '-'}</strong></span></div>","existing_code":" <div class=\"summary-row\"><span>文化课成绩</span><span><strong>${result.cultureFinalScore != null ? result.cultureFinalScore : '-'}</strong></span></div>\n <div class=\"summary-row\"><span>专业课成绩</span><span><strong>${result.professionalFinalScore != null ? result.professionalFinalScore : '-'}</strong></span></div>"}
{"path":"apps/server/src/archive/archive-report.learning.ts","start_line":19,"end_line":26,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" return sectionFrame(`\n ${sectionHeader('学情记录')}\n <div class=\"title-row\">\n <div>\n <div class=\"source\">${esc(now)} · 系统生成</div>\n <div class=\"section-title\">学情记录</div>\n </div>\n </div>"}
{"path":"apps/server/src/archive/archive-report.enrollment.ts","start_line":33,"end_line":38,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" const cultureEnrollments = enrollments.filter(\n (e) => e.courseCategory && e.courseCategory.includes('文化'),\n );\n const profEnrollments = enrollments.filter(\n (e) => e.courseCategory && e.courseCategory.includes('专业'),\n );"}
{"path":"apps/server/src/archive/archive-report.enrollment.ts","start_line":4,"end_line":5,"category":"bug","severity":"low","content":"`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`.","suggestion_code":"export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string {\n if (!enrollments?.length) return '';","existing_code":"export function buildEnrollmentSection(enrollments: StudentEnrollment[]): string {\n if (enrollments.length === 0) return '';"}
{"path":"apps/server/src/archive/archive-report.styles.ts","start_line":69,"end_line":72,"category":"bug","severity":"medium","content":"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.","suggestion_code":" .toc-row {\n display: grid; grid-template-columns: 48px 1fr 40px; align-items: center;\n height: 47px; border-bottom: 1px solid #cfe0f2;\n }","existing_code":" .toc-row {\n display: grid; grid-template-columns: 48px 1fr; align-items: center;\n height: 47px; border-bottom: 1px solid #cfe0f2;\n }"}
{"path":"apps/server/src/archive/archive-report.styles.ts","start_line":14,"end_line":17,"category":"bug","severity":"medium","content":"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).","suggestion_code":" .section-cover {\n position: relative; height: 273mm; padding: 0; overflow: hidden;\n page-break-after: always; break-after: page;\n }","existing_code":" .section-cover {\n height: 297mm; overflow: hidden;\n page-break-after: always; break-after: page;\n }"}
{"path":"apps/server/src/archive/archive-report.service.ts","start_line":53,"end_line":53,"category":"bug","severity":"medium","content":"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.","suggestion_code":" if (!student) throw new NotFoundException('学生不存在');","existing_code":" if (!student) throw new Error('学生不存在');"}
{"path":"apps/server/src/archive/archive-report.service.ts","start_line":48,"end_line":48,"category":"performance","severity":"low","content":"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.","suggestion_code":null,"existing_code":" this.learningRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),"}
{"path":"apps/server/src/archive/archive-report.exam.ts","start_line":18,"end_line":26,"category":"bug","severity":"medium","content":"进步幅度 (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.","suggestion_code":" const scored = cultureExams\n .filter((e) => e.score != null)\n .sort((a, b) => (a.examDate ?? '').localeCompare(b.examDate ?? ''));\n let improvement = '—';\n if (scored.length >= 2) {\n const first = scored[0].score!;\n const last = scored[scored.length - 1].score!;\n improvement = (last - first).toFixed(1);\n }","existing_code":" const sortedScores = cultureExams\n .map((exam) => exam.score)\n .filter((score): score is number => score !== null && score !== undefined);\n let improvement = '—';\n if (sortedScores.length >= 2) {\n const first = sortedScores[0];\n const last = sortedScores[sortedScores.length - 1];\n improvement = (last - first).toFixed(1);\n }"}
{"path":"apps/server/src/archive/archive-report.exam.ts","start_line":28,"end_line":34,"category":"bug","severity":"medium","content":"平均分 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.","suggestion_code":" const scoredExams = cultureExams.filter((e) => e.score != null);\n const avgScore =\n scoredExams.length > 0\n ? (\n scoredExams.reduce((sum, e) => sum + e.score!, 0) /\n scoredExams.length\n ).toFixed(1)\n : '-';","existing_code":" const avgScore =\n cultureExams.length > 0\n ? (\n cultureExams.reduce((sum, e) => sum + (e.score ?? 0), 0) /\n cultureExams.length\n ).toFixed(1)\n : '-';"}
{"path":"apps/server/src/archive/archive-report.exam.ts","start_line":194,"end_line":197,"category":"bug","severity":"medium","content":"最佳/均分 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.","suggestion_code":" const scored = subExams.filter((e) => e.score != null);\n const best = scored.length > 0 ? Math.max(...scored.map((e) => e.score!)) : 0;\n const avg = scored.length > 0\n ? (scored.reduce((sum, e) => sum + e.score!, 0) / scored.length).toFixed(1)\n : '-';","existing_code":" const best = Math.max(...subExams.map((e) => e.score ?? 0));\n const avg = (\n subExams.reduce((sum, e) => sum + (e.score ?? 0), 0) / subExams.length\n ).toFixed(1);"}
{"path":"apps/server/src/archive/archive-report.exam.ts","start_line":95,"end_line":97,"category":"style","severity":"low","content":"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.","suggestion_code":" <td>${e.score !== null ? e.score : '-'}</td>\n <td>${e.classAvg !== null ? e.classAvg : '-'}</td>\n <td>${e.rank !== null ? e.rank : '-'}</td>","existing_code":" <td>${e.score != null ? e.score : '-'}</td>\n <td>${e.classAvg != null ? e.classAvg : '-'}</td>\n <td>${e.rank != null ? e.rank : '-'}</td>"}
{"path":"apps/server/src/archive/dto/archive.dto.ts","start_line":4,"end_line":4,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"export class UpsertProfileDto {"}
{"path":"apps/server/src/archive/dto/archive.dto.ts","start_line":51,"end_line":51,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"export class UpsertResultDto {"}
{"path":"apps/server/src/archive/dto/archive.dto.ts","start_line":26,"end_line":26,"category":"maintainability","severity":"low","content":"`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`.","suggestion_code":null,"existing_code":"export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {}"}
{"path":"apps/server/src/archive/archive.controller.ts","start_line":211,"end_line":211,"category":"bug","severity":"medium","content":"`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.","suggestion_code":" module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file?.originalname || ''} (${category || 'other'})`,","existing_code":" module: '学生档案', action: '上传附件', targetId: result.id, targetType: 'archive_attachment', detail: `${file.originalname} (${category || 'other'})`,"}
{"path":"apps/server/src/archive/archive.controller.ts","start_line":203,"end_line":204,"category":"security","severity":"high","content":"`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.","suggestion_code":" @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))\n async uploadAttachment(","existing_code":" @UseInterceptors(FileInterceptor('file'))\n async uploadAttachment("}
{"path":"apps/server/src/archive/archive.controller.ts","start_line":226,"end_line":227,"category":"security","severity":"high","content":"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.","suggestion_code":" res.setHeader('Content-Disposition', `attachment; filename=\"${encodeURIComponent(fileName)}\"`);","existing_code":" res.setHeader('Content-Type', mimeType);\n res.setHeader('Content-Disposition', `inline; filename=\"${encodeURIComponent(fileName)}\"`);"}
{"path":"apps/server/src/archive/archive.controller.ts","start_line":228,"end_line":229,"category":"bug","severity":"medium","content":"`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.","suggestion_code":" const stream = fs.createReadStream(fullPath);\n stream.on('error', (err) => {\n if (!res.headersSent) res.status(500);\n res.end();\n });\n stream.pipe(res);","existing_code":" const stream = fs.createReadStream(fullPath);\n stream.pipe(res);"}
{"path":"apps/server/src/archive/archive.controller.ts","start_line":65,"end_line":65,"category":"security","severity":"low","content":"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.","suggestion_code":null,"existing_code":" module: '学生档案', action: '更新档案信息', targetId: studentId, targetType: 'student_profile', detail: JSON.stringify(dto),"}
{"path":"apps/server/src/attendance-devices/attendance-devices.controller.ts","start_line":41,"end_line":43,"category":"bug","severity":"medium","content":"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.","suggestion_code":" const result = await this.service.create(dto);\n try {\n await this.logService.log({\n userId: req.user?.id,","existing_code":" const result = await this.service.create(dto);\n await this.logService.log({\n userId: req.user?.id,"}
{"path":"apps/server/src/attendance-devices/attendance-devices.controller.ts","start_line":31,"end_line":34,"category":"bug","severity":"medium","content":"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.","suggestion_code":" const parsedStatus =\n status === AttendanceDeviceStatus.ACTIVE || status === AttendanceDeviceStatus.DISABLED\n ? status\n : undefined;\n const parsedClassroomId = classroomId && Number.isFinite(Number(classroomId)) ? Number(classroomId) : undefined;\n return this.service.findAll({\n classroomId: parsedClassroomId,\n status: parsedStatus,\n });","existing_code":" return this.service.findAll({\n classroomId: classroomId ? Number(classroomId) : undefined,\n status,\n });"}
{"path":"apps/server/src/attendance-devices/dto/attendance-device.dto.ts","start_line":15,"end_line":16,"category":"bug","severity":"medium","content":"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.","suggestion_code":" @IsInt()\n @IsPositive()\n classroomId: number;","existing_code":" @IsInt()\n classroomId: number;"}
{"path":"apps/server/src/attendance-devices/dto/attendance-device.dto.ts","start_line":27,"end_line":29,"category":"security","severity":"low","content":"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.","suggestion_code":" @IsOptional()\n @IsString()\n @MaxLength(1000)\n notes?: string;","existing_code":" @IsOptional()\n @IsString()\n notes?: string;"}
{"path":"apps/server/src/attendance-devices/dto/attendance-device.dto.ts","start_line":32,"end_line":35,"category":"maintainability","severity":"low","content":"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.","suggestion_code":"import { PartialType } from '@nestjs/mapped-types';\n\nexport class UpdateAttendanceDeviceDto extends PartialType(CreateAttendanceDeviceDto) {}","existing_code":"export class UpdateAttendanceDeviceDto {\n @IsOptional()\n @IsString()\n @IsNotEmpty()"}
{"path":"apps/server/src/archive/archive.service.ts","start_line":300,"end_line":301,"category":"security","severity":"high","content":"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.","suggestion_code":null,"existing_code":" async deleteAttachment(id: number) {\n const entity = await this.attachmentRepo.findOne({ where: { id } });"}
{"path":"apps/server/src/archive/archive.service.ts","start_line":273,"end_line":273,"category":"performance","severity":"medium","content":"`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.","suggestion_code":null,"existing_code":" fs.writeFileSync(filePath, file.buffer);"}
{"path":"apps/server/src/archive/archive.service.ts","start_line":273,"end_line":275,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" fs.writeFileSync(filePath, file.buffer);\n\n const entity = this.attachmentRepo.create({"}
{"path":"apps/server/src/archive/archive.service.ts","start_line":271,"end_line":271,"category":"bug","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" const filename = `${studentId}_${Date.now()}${ext}`;"}
{"path":"apps/server/src/archive/archive.service.ts","start_line":281,"end_line":281,"category":"security","severity":"medium","content":"`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`.","suggestion_code":null,"existing_code":" mimeType: file.mimetype,"}
{"path":"apps/server/src/archive/archive.service.ts","start_line":290,"end_line":290,"category":"bug","severity":"low","content":"`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.","suggestion_code":null,"existing_code":" const fullPath = this.resolveAttachmentPath(entity.filePath);"}
{"path":"apps/server/src/archive/archive.service.ts","start_line":149,"end_line":149,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" if (entity.status === 'archived') throw new BadRequestException('报名记录已归档');"}
{"path":"apps/server/src/ai-config/ai-config.helpers.ts","start_line":108,"end_line":108,"category":"security","severity":"medium","content":"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.","suggestion_code":null,"existing_code":"export function isPrivateHost(hostname: string): boolean {"}
{"path":"apps/server/src/ai-config/ai-config.helpers.ts","start_line":280,"end_line":284,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" lookup(hostname, { all: true, family: 0 }, (dnsErr, addresses) => {\n if (dnsErr || !addresses || addresses.length === 0) {\n reject(new Error('DNS 解析失败'));\n return;\n }"}
{"path":"apps/server/src/ai-config/ai-config.helpers.ts","start_line":304,"end_line":304,"category":"bug","severity":"low","content":"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).","suggestion_code":null,"existing_code":" headers: { ...headers, Host: hostname },"}
{"path":"apps/server/src/ai-config/ai-config.helpers.ts","start_line":34,"end_line":35,"category":"security","severity":"low","content":"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.","suggestion_code":null,"existing_code":" // 32 hex pairs → 32 bytes\n return Buffer.from('ff'.repeat(32), 'hex');"}
{"path":"apps/server/src/ai-config/ai-config.helpers.ts","start_line":56,"end_line":58,"category":"maintainability","severity":"low","content":"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'.","suggestion_code":null,"existing_code":" throw new InternalServerErrorException(\n 'AI_CONFIG_ENCRYPTION_KEY 格式无效base64 编码须为标准格式(无多余 padding',\n );"}
{"path":"apps/server/src/ai-config/ai-config.helpers.ts","start_line":247,"end_line":258,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" let addresses: { address: string; family: number }[];\n try {\n addresses = await resolveHostnames(hostname);\n } catch {\n throw new BadRequestException('无法解析域名');\n }\n\n for (const { address } of addresses) {\n if (isPrivateHost(address)) {\n throw new BadRequestException('域名解析到内网地址');\n }\n }"}
{"path":"apps/server/src/ai-config/ai-config.helpers.ts","start_line":313,"end_line":317,"category":"bug","severity":"low","content":"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.","suggestion_code":null,"existing_code":" if (status >= 300 && status < 400 && res.headers.location) {\n res.resume();\n res.destroy();\n return reject(new Error('禁止重定向'));\n }"}
{"path":"apps/server/src/attendance-devices/attendance-devices.service.ts","start_line":43,"end_line":44,"category":"bug","severity":"medium","content":"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.","suggestion_code":" const deviceSn = this.normalizeSn(dto.deviceSn);\n if (!deviceSn) throw new BadRequestException('SN 不能为空');\n await this.assertClassroomExists(dto.classroomId);","existing_code":" const deviceSn = this.normalizeSn(dto.deviceSn);\n await this.assertClassroomExists(dto.classroomId);"}
{"path":"apps/server/src/attendance-devices/attendance-devices.service.ts","start_line":45,"end_line":46,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" const exists = await this.repo.findOne({ where: { deviceSn } });\n if (exists) throw new BadRequestException(`SN ${deviceSn} 已绑定教室`);"}
{"path":"apps/server/src/attendance-devices/attendance-devices.service.ts","start_line":62,"end_line":63,"category":"style","severity":"low","content":"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).","suggestion_code":" if (dto.classroomId !== undefined && dto.classroomId !== null) await this.assertClassroomExists(dto.classroomId);\n if (dto.deviceSn !== undefined && dto.deviceSn !== null) {","existing_code":" if (dto.classroomId != null) await this.assertClassroomExists(dto.classroomId);\n if (dto.deviceSn != null) {"}
{"path":"apps/server/src/attendance-devices/attendance-devices.service.ts","start_line":63,"end_line":68,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" if (dto.deviceSn != null) {\n const deviceSn = this.normalizeSn(dto.deviceSn);\n const exists = await this.repo.findOne({ where: { deviceSn } });\n if (exists && exists.id !== id) throw new BadRequestException(`SN ${deviceSn} 已绑定教室`);\n patch.deviceSn = deviceSn;\n }"}
{"path":"apps/server/src/attendance/attendance-calendar.service.ts","start_line":33,"end_line":36,"category":"bug","severity":"high","content":"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.","suggestion_code":" private getWeekDayForDate(date: string): number {\n const day = dayjs(`${date}T00:00:00+08:00`).day();\n return day === 0 ? 7 : day;\n }","existing_code":" private getWeekDayForDate(date: string): number {\n const day = new Date(`${date}T00:00:00+08:00`).getUTCDay();\n return day === 0 ? 7 : day;\n }"}
{"path":"apps/server/src/attendance/attendance-calendar.service.ts","start_line":22,"end_line":22,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" const now = dayjs().utcOffset(8);"}
{"path":"apps/server/src/attendance/attendance-calendar.service.ts","start_line":76,"end_line":79,"category":"bug","severity":"low","content":"`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.","suggestion_code":" const weekStartDate = weekStart.slice(0, 10);\n const start = new Date(`${weekStartDate}T00:00:00+08:00`);\n const end = new Date(start);\n end.setDate(start.getDate() + 6);\n const endStr = dayjs(end).utcOffset(8).format('YYYY-MM-DD');","existing_code":" const start = new Date(weekStart);\n const end = new Date(start);\n end.setDate(start.getDate() + 6);\n const endStr = dayjs(end).utcOffset(8).format('YYYY-MM-DD');"}
{"path":"apps/server/src/attendance/attendance-leave-sync.service.ts","start_line":45,"end_line":47,"category":"performance","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" for (const userId of userIds) {\n try {\n const leaves = await this.dingTalkService.fetchDailyLeaveStatus(userId, date);"}
{"path":"apps/server/src/attendance/attendance-leave-sync.service.ts","start_line":48,"end_line":51,"category":"bug","severity":"medium","content":"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.","suggestion_code":null,"existing_code":" for (const leave of leaves) {\n await this.upsertLeave(leave);\n synced++;\n }"}
{"path":"apps/server/src/attendance/attendance-leave-sync.service.ts","start_line":68,"end_line":70,"category":"bug","severity":"low","content":"`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).","suggestion_code":null,"existing_code":" const existing = await this.dingLeaveRawRepo.findOne({\n where: { dingId: result.procInstId },\n });"}
{"path":"apps/server/src/attendance/attendance-leave-sync.service.ts","start_line":72,"end_line":74,"category":"bug","severity":"low","content":"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).","suggestion_code":null,"existing_code":" Object.assign(existing, {\n dingUserId: result.userId,\n workDate: result.workDate,"}
{"path":"apps/server/src/attendance/attendance-leave-sync.service.ts","start_line":100,"end_line":100,"category":"maintainability","severity":"low","content":"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.","suggestion_code":null,"existing_code":" matchStatus: 'unmatched',"}
{"path":"apps/server/src/attendance/attendance-generation.service.ts","start_line":107,"end_line":110,"category":"performance","severity":"medium","content":"N+1 query / redundant computation inside the nested loop: `mapScheduleTimeToSession` calls `ensureAttendancePeriodConfigs()` which performs DB queries (count + find) on every call, and it is awaited once per schedule per day (e.g. 4 schedules × 7 days = 28+ DB round trips). Also `classStudents.filter(isClassStudentActiveOnDate)` is recomputed for every schedule even though it only depends on `dateStr`. Pre-resolve the session for each schedule once (e.g. `Map<scheduleId, session>` built with `Promise.all`) before the date loop, and compute `classStudentsForDate` once per date outside the schedule loop.","suggestion_code":" const session = scheduleSessions.get(sched.id)!;","existing_code":" const session = await this.mapScheduleTimeToSession(sched.startTime);\n const classStudentsForDate = classStudents.filter((cs) =>\n isClassStudentActiveOnDate(cs, dateStr),\n );"}
{"path":"apps/server/src/attendance/attendance-generation.service.ts","start_line":214,"end_line":214,"category":"performance","severity":"medium","content":"Sequential awaited DB calls in a loop: `mapScheduleTimeToSession` hits the DB (via `ensureAttendancePeriodConfigs`) for every schedule, and each iteration awaits the previous one. Resolve the session for all schedules in parallel (single call to fetch the enabled periods, then match in memory), e.g. `await Promise.all(schedules.map(...))` or fetch periods once before the loop.","suggestion_code":null,"existing_code":" if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) {"}
{"path":"apps/server/src/attendance/attendance-generation.service.ts","start_line":100,"end_line":101,"category":"bug","severity":"medium","content":"Day-of-week/timezone inconsistency: `new Date(dateFrom)` with a date-only string is parsed as UTC midnight, and `d.getDay()` is evaluated in the server's local timezone, while `dateStr` is formatted with a fixed UTC+8 offset. On servers running in a timezone west of UTC (or any TZ where the UTC date differs from the +8 date), `weekDay` will not correspond to `dateStr`, so records can be generated for the wrong weekday. Derive both from the same UTC+8 `dayjs` instance.","suggestion_code":" const d8 = dayjs(d).utcOffset(8);\n const dateStr = d8.format('YYYY-MM-DD');\n const weekDay = d8.day() === 0 ? 7 : d8.day();","existing_code":" const dateStr = dayjs(d).utcOffset(8).format('YYYY-MM-DD');\n const weekDay = d.getDay() === 0 ? 7 : d.getDay();"}
{"path":"apps/server/src/attendance/attendance-generation.service.ts","start_line":254,"end_line":257,"category":"maintainability","severity":"medium","content":"`clear()` followed by `save()` is not transactional (also in `resetAttendancePeriodConfigs`). If `save` fails or a concurrent request reads/configures periods in between, the existing period configs are lost and the table is left empty or inconsistent. Wrap both operations in `this.dataSource.transaction(...)` using the transaction's repository.","suggestion_code":" await this.dataSource.transaction(async (manager) => {\n const repo = manager.getRepository(AttendancePeriodConfig);\n await repo.clear();\n await repo.save(normalized.map((period) => repo.create(period)));\n });","existing_code":" await this.attendancePeriodConfigRepo.clear();\n await this.attendancePeriodConfigRepo.save(\n normalized.map((period) => this.attendancePeriodConfigRepo.create(period)),\n );"}
{"path":"apps/server/src/attendance/attendance-generation.service.ts","start_line":266,"end_line":267,"category":"maintainability","severity":"low","content":"Dead code: `mapLessonScheduleTimeToSession` is a private method that is never called anywhere in this file, and it references a `night_check` period key that does not exist in `defaultAttendancePeriods` or in `saveAttendancePeriodConfigs` handling. Remove it to avoid confusion with the actually-used `mapScheduleTimeToSession`.","suggestion_code":null,"existing_code":" private mapLessonScheduleTimeToSession(startTime: string): string {\n const hour = parseInt(startTime.slice(0, 2), 10);"}
{"path":"apps/server/src/attendance/attendance-generation.service.ts","start_line":155,"end_line":158,"category":"maintainability","severity":"low","content":"Duplicate logic / shadowed import: the private `toMinutes` method duplicates the `toMinutes` imported from './attendance-time'. Because class methods shadow module-level imports, every unqualified `toMinutes(...)` call in this file resolves to the private copy, leaving the imported one unused (dead import). Keep only one implementation to avoid two definitions drifting apart.","suggestion_code":null,"existing_code":" private toMinutes(time: string): number {\n const [hour, minute] = time.split(':').map(Number);\n return hour * 60 + minute;\n }"}
{"path":"apps/server/src/attendance/attendance-generation.service.ts","start_line":22,"end_line":22,"category":"other","severity":"low","content":"Likely copy-paste typo in the default config: the `afternoon` (14:0017:00) period is labeled '晚课' (evening class), which overlaps semantically with `evening_study` labeled '晚自习'. It is probably meant to be '午课'/'下午课'.","suggestion_code":null,"existing_code":" { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 },"}
{"path":"apps/server/src/attendance/attendance-generation.service.ts","start_line":229,"end_line":231,"category":"bug","severity":"low","content":"Missing time-format validation: if `startTime`/`endTime` are not `HH:mm`, `toMinutes` returns NaN and all comparisons (`NaN <= NaN`, `NaN < NaN`) evaluate to false, so invalid times silently pass validation and get persisted. Validate the `HH:mm` format before comparing.","suggestion_code":" if (!/^\\d{2}:\\d{2}$/.test(period.startTime) || !/^\\d{2}:\\d{2}$/.test(period.endTime)) {\n throw new BadRequestException(`${label} 的时间格式必须为 HH:mm`);\n }\n if (toMinutes(period.endTime) <= toMinutes(period.startTime)) {\n throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`);\n }","existing_code":" if (toMinutes(period.endTime) <= toMinutes(period.startTime)) {\n throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`);\n }"}
{"path":"apps/server/src/attendance/attendance-import.service.ts","start_line":87,"end_line":92,"category":"bug","severity":"high","content":"The safety timeout can corrupt the single-import guarantee. If the timer fires while the original import is still genuinely running (which is the only case where it can fire, since `finally` always clears it on the normal path), it sets `isRunning = false` but does nothing to stop the hung import. A second import can then start concurrently, and both share the same `progressSubject` / `importingUserId`, so: (1) the old import's later `emit` calls get tagged with the new user's ID, breaking SSE scoping; (2) when the old import finally exits, its `finally` block resets `isRunning = false` and `importingUserId = undefined` while the new import is still running, allowing a third import to start. Consider using a per-run token/generation counter so only the matching run resets the flags, and refuse starting a new import until the previous run has truly finished.","suggestion_code":null,"existing_code":" const safetyTimer = setTimeout(() => {\n if (this.isRunning) {\n this.logger.error('Import safety timeout triggered — force-resetting isRunning');\n this.isRunning = false;\n }\n }, SAFETY_TIMEOUT_MS);"}
{"path":"apps/server/src/attendance/attendance-import.service.ts","start_line":144,"end_line":144,"category":"bug","severity":"medium","content":"Batch save failures are collected into `errors` and the loop continues, but the method still returns `success: true`. If every batch fails (e.g., unique-constraint violation or a transient DB error), callers that rely on the `success` flag will treat a fully failed import as successful. Return `success: false` (or a partial-success indicator) when `errors.length > 0`, and reflect the errors in the 'complete' progress event as well.","suggestion_code":null,"existing_code":" return { success: true, imported, skipped, matched, errors, duration };"}
{"path":"apps/server/src/attendance/attendance-import.service.ts","start_line":295,"end_line":295,"category":"performance","severity":"medium","content":"For every record that lacks `userName`, `resolveStudentName` runs two sequential DB queries (mapping lookup + student lookup). With thousands of imported records this is an N+1 pattern (up to 2×N queries, executed in `Promise.all` batches of 100). Batch-load the `StudentDingMapping`/`Student` rows for the whole page with `In(...)` and resolve names in memory instead.","suggestion_code":null,"existing_code":" entity.userName = r.userName || await this.resolveStudentName(r.userId);"}
{"path":"apps/server/src/attendance/attendance-import.service.ts","start_line":254,"end_line":256,"category":"bug","severity":"medium","content":"`In(dingIds)` builds a single `WHERE dingId IN (...)` with one bind variable per record. For large imports the clause can exceed the database's max bind-variable/expression limit (e.g., ~999 for SQLite, 65535 for MySQL/PostgreSQL), causing the whole import to fail with a generic error. Chunk the id list (e.g., 5001000 ids per query) and merge the results.","suggestion_code":null,"existing_code":" const existing = await this.dingRawRepo.find({\n where: { dingId: In(dingIds) },\n });"}
{"path":"apps/server/src/attendance/attendance-import.service.ts","start_line":107,"end_line":107,"category":"bug","severity":"low","content":"Deduplication only compares against existing DB records, not against other records in the same fetched result. If the same `checkId` appears twice in `rawResults` (or is returned by overlapping API responses), both entries are treated as new; the second insert then violates the unique constraint and is reported as a batch error while the import still claims `success: true`. Deduplicate `rawResults` in memory by `checkId` before the DB lookup/filter.","suggestion_code":null,"existing_code":" const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));"}
{"path":"apps/server/src/attendance/attendance-import.service.ts","start_line":30,"end_line":30,"category":"maintainability","severity":"low","content":"`progressSubject` is never completed or replaced, and there is no subscription cleanup after an import finishes. Long-lived SSE subscriptions that don't unsubscribe will accumulate over repeated imports. Consider completing the Subject (or replacing it per import run) once the pipeline finishes/errors.","suggestion_code":null,"existing_code":" private progressSubject = new Subject<ImportProgressEvent>();"}
{"path":"apps/server/src/attendance/attendance-device.ts","start_line":34,"end_line":35,"category":"style","severity":"low","content":"Uses non-strict equality `!=` (twice here), which violates the project's strict-equality rule. Note that `record.classId` is typed `number` (non-nullable) in the entity, so this filter is only a runtime-null guard; if the null/undefined check is intended, express it strictly as `!== null && !== undefined` (or use a nullable field type) instead of `!=`.","suggestion_code":" ...records.map((record) => record.classId).filter((id): id is number => id !== null && id !== undefined),\n ...(classroomId !== null && classroomId !== undefined ? [classroomId] : []),","existing_code":" ...records.map((record) => record.classId).filter((id): id is number => id != null),\n ...(classroomId != null ? [classroomId] : []),"}
{"path":"apps/server/src/attendance/attendance-device.ts","start_line":41,"end_line":41,"category":"maintainability","severity":"low","content":"Hardcoded business status literal `'active'`. The entity already exports the `AttendanceDeviceStatus` enum (`ACTIVE = 'active'`), so use the enum constant to avoid string-literal drift and keep status values centralized.","suggestion_code":" where: { classroomId: In(classroomIds), status: AttendanceDeviceStatus.ACTIVE },","existing_code":" where: { classroomId: In(classroomIds), status: 'active' },"}
{"path":"apps/server/src/attendance/attendance-device.ts","start_line":25,"end_line":26,"category":"performance","severity":"low","content":"These two repository queries are independent (SN lookup vs. classroom lookup) but are awaited sequentially, adding two round-trip latencies. They can be executed concurrently with `Promise.all`, which is preferable for independent async operations.","suggestion_code":null,"existing_code":" const devices = await attendanceDeviceRepo.find({\n where: { deviceSn: In(sns) },"}
{"path":"apps/server/src/attendance/attendance-import.controller.ts","start_line":35,"end_line":40,"category":"security","severity":"high","content":"Missing class-scope authorization for match/auto-match. Unlike `getDingRaw` (assertClassAccess) and `importFromDingTalk` (getTeacherClassDingUserIds), neither `matchDingRecord` nor `autoMatch` verifies that the raw record/student belongs to the requester's accessible classes. The underlying `AttendanceRecordMutationService.matchDingRecord` simply loads the record by id and saves the match, and `autoMatchDingRecords` iterates ALL unmatched records globally. A user holding only `attendance:edit` (e.g., a teacher) can match records belonging to other classes by guessing IDs and trigger a global auto-match. Suggest resolving the raw record's class and calling `assertClassAccess` before matching, and scoping `autoMatch` to the user's accessible classes (or requiring `canManageAllAttendance`).","suggestion_code":null,"existing_code":" async matchDingRecord(\n @Param('id', ParseIntPipe) id: number,\n @Body() dto: MatchDingRecordDto,\n @Request() req: { user: RequestUser },\n ) {\n const result = await this.service.matchDingRecord(id, dto);"}
{"path":"apps/server/src/attendance/attendance-import.controller.ts","start_line":135,"end_line":138,"category":"bug","severity":"medium","content":"SSE stream can hang indefinitely. `importService.progress$` is backed by a plain RxJS `Subject` (see attendance-import.service.ts), so any event emitted before the subscriber connects is lost. If the client connects when no import is running — or after the import already finished — the observable never emits and never reaches the `complete`/`error` phase, so `subscriber.complete()` is never called and the connection stays open until the client disconnects. There is also no heartbeat/keep-alive event, which can cause idle connection drops behind proxies. Consider exposing a replay of the last event (e.g., BehaviorSubject/ReplaySubject(1)), emitting an immediate \"no active import\" snapshot, and adding a keep-alive heartbeat plus a timeout that terminates stale connections.","suggestion_code":null,"existing_code":" return new Observable<SseEvent>((subscriber) => {\n const subscription = this.importService.progress$\n .pipe(filter((event) => event.userId === userId))\n .subscribe({"}
{"path":"apps/server/src/attendance/attendance-import.controller.ts","start_line":102,"end_line":108,"category":"bug","severity":"low","content":"Inconsistent error handling in the import endpoint. When another import is already running, `AttendanceImportService.importFromDingTalk` throws a plain `Error('An import is already in progress')` which surfaces to the client as an HTTP 500; and when the pipeline itself fails, the service returns `{ success: false, errors }` but this controller still replies HTTP 200 and writes a normal audit entry that omits the error details. Consider catching the concurrency exception and mapping it to a 409 Conflict, and when `result.success === false` include `result.errors` in the audit log detail / response so failures are observable.","suggestion_code":null,"existing_code":" const result = await this.importService.importFromDingTalk({\n startDate,\n endDate,\n userIds,\n autoMatch: true,\n userId: req.user.id,\n });"}
{"path":"apps/server/src/attendance/attendance-lesson-status.ts","start_line":124,"end_line":126,"category":"performance","severity":"medium","content":"Performance: this query loads the student's ENTIRE leave history (every leave row ever synced) and then filters/sorts in memory. It runs once per student during finalize (from buildLessonRecord inside Promise.all over classStudents, inside a transaction), so a class of N students triggers N full-history queries while the transaction is held open. Push the time-window filter down to SQL — e.g. filter on workDate between window.dateFrom/window.dateTo (uses the existing workDate index) or startTime <= window.end && endTime >= window.start — and select only the needed columns instead of materializing all rows.","suggestion_code":" const leaves = await dingLeaveRawRepo.find({\n where: {\n matchedStudentId: studentId,\n startTime: LessThanOrEqual(new Date(window.end)),\n endTime: MoreThanOrEqual(new Date(window.start)),\n },\n });","existing_code":" const leaves = await dingLeaveRawRepo.find({\n where: { matchedStudentId: studentId },\n });"}
{"path":"apps/server/src/attendance/attendance-lesson-status.ts","start_line":128,"end_line":134,"category":"bug","severity":"low","content":"The function is named findApprovedLeaveForStudent and its doc comment promises only approved leave ('钉钉已审批通过的请假'), but the filter never checks that approvedAt is non-null; it only sorts by it. The DingTalk sync currently stores only finished approvals, but the entity column is nullable and this contract is not enforced at read time — any pending/rejected record present in the table would be treated as a valid leave and incorrectly change the student's status from absent to leave. Add a defensive approvedAt check.","suggestion_code":" const overlapping = leaves.filter(\n (leave) =>\n leave.approvedAt &&\n leave.startTime &&\n leave.endTime &&\n leave.startTime.getTime() <= window.end &&\n leave.endTime.getTime() >= window.start,\n );","existing_code":" const overlapping = leaves.filter(\n (leave) =>\n leave.startTime &&\n leave.endTime &&\n leave.startTime.getTime() <= window.end &&\n leave.endTime.getTime() >= window.start,\n );"}
{"path":"apps/server/src/attendance/attendance-dingtalk.ts","start_line":89,"end_line":93,"category":"maintainability","severity":"medium","content":"Nested ternary buried inside a `||` expression violates the project rule that nested ternary expressions are not allowed, and the fallback chain (metadataRecord?.punchSource -> attendanceType for non-standard types -> primary.punchSource) is hard to follow. Extract this into a small helper with if/else statements and add a comment explaining the fallback intent.","suggestion_code":null,"existing_code":" const source =\n metadataRecord?.punchSource ||\n (metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)\n ? metadataRecord.attendanceType\n : primary.record.punchSource);"}
{"path":"apps/server/src/attendance/attendance-dingtalk.ts","start_line":81,"end_line":82,"category":"maintainability","severity":"low","content":"Business values such as DingTalk's attendance types 'OnDuty'/'OffDuty' (and the default advance of 30 minutes in getLessonAttendanceWindow) are hardcoded and duplicated in getLessonPunchMetadata. Extract them into named constants so the semantics and any API-driven changes are centralized.","suggestion_code":null,"existing_code":" !!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||\n !['OnDuty', 'OffDuty'].includes(record.attendanceType),"}
{"path":"apps/server/src/attendance/attendance-dingtalk.ts","start_line":16,"end_line":16,"category":"maintainability","severity":"low","content":"The fixed timezone '+08:00' is hardcoded in multiple date constructions (here and in getLessonPunchMetadata), and the same `new Date(`${lessonDate}T${time}:00+08:00`)` pattern is duplicated across functions. If the business timezone ever changes or the server runs outside Asia/Shanghai, these silently diverge from `shiftDate`/`dayjs.utc` usage. Centralize into a single timezone constant/helper to keep them consistent.","suggestion_code":null,"existing_code":" const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime();"}
{"path":"apps/server/src/attendance/attendance-record-mutation.service.ts","start_line":66,"end_line":69,"category":"performance","severity":"medium","content":"Performance: this loop issues one `save()` per unmatched record sequentially (N+1). These updates are independent, so they should be batched — either collect all matches and run them with `Promise.all`, or use a single bulk `UPDATE ... WHERE id IN (...)` via `createQueryBuilder().update()`. Sequential awaits will be slow when the unmatched set is large.","suggestion_code":" await Promise.all(\n unmatched.map((record) => {\n const studentId = dingToStudentId.get(record.dingUserId);\n if (studentId == null) return Promise.resolve();\n return this.dingRawRepo.update(record.id, {\n matchedStudentId: studentId,\n matchStatus: 'matched',\n });\n }),\n );","existing_code":" record.matchedStudentId = studentId;\n record.matchStatus = 'matched';\n await this.dingRawRepo.save(record);\n matched++;"}
{"path":"apps/server/src/attendance/attendance-record-mutation.service.ts","start_line":61,"end_line":64,"category":"bug","severity":"medium","content":"Atomicity: `autoMatchDingRecords` mutates records one by one without a transaction. If any `save` fails mid-loop, the exception propagates and leaves a partial state (some records 'matched', the rest still 'unmatched') with no rollback. Wrap the whole matching loop in `this.dataSource.transaction(...)` so the operation is all-or-nothing.","suggestion_code":null,"existing_code":" let matched = 0;\n for (const record of unmatched) {\n const studentId = dingToStudentId.get(record.dingUserId);\n if (studentId == null) continue;"}
{"path":"apps/server/src/attendance/attendance-record-mutation.service.ts","start_line":96,"end_line":108,"category":"maintainability","severity":"low","content":"Maintainability: the mutex + transaction + session/completed re-check + fresh-record re-fetch logic in `update()` and `remove()` is duplicated almost verbatim. Extract a private helper (e.g. `withSessionLock(id, fn)`) that performs the mutex acquisition, transaction, session validation and fresh-record lookup, and have both `update()` and `remove()` delegate to it so the two paths cannot drift apart.","suggestion_code":null,"existing_code":" return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>\n this.dataSource.transaction(async (manager) => {\n const recordRepo = manager.getRepository(AttendanceRecord);\n const sessionRepo = manager.getRepository(AttendanceSession);\n\n // Re-check session status inside the transaction while holding the lock\n const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });\n if (!session || session.status === 'completed') {\n throw new BadRequestException('已完成考勤的记录不允许修改或删除');\n }\n\n const freshRecord = await recordRepo.findOne({ where: { id } });\n if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);"}
{"path":"apps/server/src/attendance/attendance-record-mutation.service.ts","start_line":42,"end_line":44,"category":"bug","severity":"medium","content":"Data integrity: `matchDingRecord` accepts any `studentId` without verifying the student exists. An invalid id will fail at the DB with a FK constraint error (surfacing as a 500) rather than a clean 4xx, and a valid-but-different id silently overwrites an already-matched record. Validate the student's existence (throw NotFoundException/BadRequestException) before assigning, and consider rejecting overwrites of records already in 'matched' state unless explicitly intended.","suggestion_code":null,"existing_code":" record.matchedStudentId = dto.studentId;\n record.matchStatus = 'matched';\n return this.dingRawRepo.save(record);"}
{"path":"apps/server/src/attendance/attendance-mutex.ts","start_line":12,"end_line":13,"category":"bug","severity":"medium","content":"The queue has no timeout or abort mechanism. If the wrapped `fn` (e.g. the DB transaction in attendance-record-mutation.service.ts) never settles — DB stall, deadlock in the connection pool — every subsequent `runExclusive` call for that sessionId will wait forever on `await tail`, and each call appends a new tail promise to `queueTails` that is never removed (the `finally` cleanup only runs when the last queued op completes). Over time this permanently blocks the session and grows the map without bound. Consider adding a per-call timeout (settle with an error after a configurable duration) and/or an `abort(sessionId)`/queue-clear API so a hung operation can be evicted instead of wedging the session forever.","suggestion_code":null,"existing_code":" this.queueTails.set(sessionId, newTail);\n await tail;"}
{"path":"apps/server/src/attendance/attendance-mutex.ts","start_line":8,"end_line":8,"category":"maintainability","severity":"low","content":"This mutex is non-reentrant: if `fn` itself calls `runExclusive` with the same `sessionId` (directly or transitively through a nested service call), the inner call awaits the outer call's `newTail`, which only resolves after the outer `fn` completes — a guaranteed deadlock with no detection. The current call sites don't nest, but this is a latent hazard worth guarding against (e.g. detect same-session re-entry and throw) or at least documenting on the class.","suggestion_code":null,"existing_code":" async runExclusive<T>(sessionId: number, fn: () => Promise<T>): Promise<T> {"}
{"path":"apps/server/src/attendance/attendance-query.service.ts","start_line":80,"end_line":80,"category":"security","severity":"medium","content":"Accessibility filter is only applied when `query.classId` is NOT provided. If a caller passes both `classId` and `accessibleClassIds` where the classId is outside the user's accessible set, the classId filter wins and the accessibility check is silently skipped — a potential authorization bypass. Either have the controller validate classId against accessibleClassIds, or intersect here, e.g.: if `accessibleClassIds && !accessibleClassIds.includes(query.classId)` return zeroed summary.","suggestion_code":" if (accessibleClassIds && query.classId && !accessibleClassIds.includes(query.classId)) {\n return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 };\n }\n if (!query.classId && accessibleClassIds) {","existing_code":" if (!query.classId && accessibleClassIds) {"}
{"path":"apps/server/src/attendance/attendance-query.service.ts","start_line":145,"end_line":145,"category":"security","severity":"medium","content":"Same authorization concern as in `getSummary`: when `query.classId` is supplied, `accessibleClassIds` is ignored, so a caller can query attendance records of any class by passing an arbitrary classId. Ensure the controller verifies `classId` membership in `accessibleClassIds`, or enforce it here.","suggestion_code":" if (query.classId) {\n if (accessibleClassIds && !accessibleClassIds.includes(query.classId)) {\n return { list: [], total: 0, page, pageSize };\n }\n qb.andWhere('ar.classId = :classId', { classId: query.classId });\n } else if (accessibleClassIds) {","existing_code":" } else if (accessibleClassIds) {"}
{"path":"apps/server/src/attendance/attendance-query.service.ts","start_line":250,"end_line":250,"category":"security","severity":"medium","content":"When `query.classId` is provided, `accessibleClassIds` is completely ignored, so a user could query DingTalk raw records for a class they have no access to by simply passing the classId. Apply an intersection check so classId is only honored when it falls within the user's accessible set.","suggestion_code":" const scopedClassIds = query.classId\n ? (accessibleClassIds && !accessibleClassIds.includes(query.classId) ? [] : [query.classId])\n : accessibleClassIds;","existing_code":" const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds;"}
{"path":"apps/server/src/attendance/attendance-query.service.ts","start_line":95,"end_line":95,"category":"performance","severity":"medium","content":"`getMany()` materializes every matching row and the aggregate counts are computed in JS. For large date ranges / many classes this loads all records into memory. Prefer SQL-side aggregation, e.g. COUNT + SUM(CASE WHEN status='present' THEN 1 ELSE 0 END) via `getRawOne()`/`getCount()`, so only one aggregate row is returned.","suggestion_code":null,"existing_code":" const rows = await qb.getMany();"}
{"path":"apps/server/src/attendance/attendance-query.service.ts","start_line":179,"end_line":181,"category":"maintainability","severity":"low","content":"When `accessibleClassIds` is provided, the returned classes are derived purely from the accessible id list, regardless of whether any attendance records exist — this contradicts the method's stated purpose (\"Get distinct classes with attendance records\"). If only classes that actually have attendance data should be listed, intersect accessibleClassIds with the distinct classIds present in the attendance table.","suggestion_code":null,"existing_code":" const rows: Array<{ classId: string | number }> = accessibleClassIds\n ? accessibleClassIds.map((classId) => ({ classId }))\n : await qb.orderBy('ar.classId', 'ASC').getRawMany();"}
{"path":"apps/server/src/attendance/attendance-query.service.ts","start_line":134,"end_line":135,"category":"maintainability","severity":"low","content":"Pagination and date/status filter-building logic is duplicated between `findAll` and `getDingRaw` (identical page/pageSize clamping and dateFrom/dateTo conditions). Consider extracting a shared helper to keep filter semantics consistent and avoid drift.","suggestion_code":null,"existing_code":" const page = Math.max(1, Math.floor(Number(query.page) || 1));\n const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20)));"}
{"path":"apps/server/src/attendance/attendance-time.ts","start_line":3,"end_line":6,"category":"bug","severity":"medium","content":"`toMinutes` does not validate its input: if `time` is not exactly `HH:MM` (e.g. missing colon, non-numeric segment, or just \"8\"), `hour * 60 + minute` evaluates to `NaN` and silently propagates to callers (e.g. overnight/overlap comparisons in attendance-dingtalk and attendance-generation), potentially breaking attendance logic without any error signal. Suggest validating and throwing a descriptive error for malformed input.","suggestion_code":"export function toMinutes(time: string): number {\n const parts = time.split(':').map(Number);\n if (parts.length !== 2 || parts.some(Number.isNaN)) {\n throw new Error(`Invalid time format: ${time}`);\n }\n const [hour, minute] = parts;\n return hour * 60 + minute;\n}","existing_code":"export function toMinutes(time: string): number {\n const [hour, minute] = time.split(':').map(Number);\n return hour * 60 + minute;\n}"}
{"path":"apps/server/src/attendance/attendance-time.ts","start_line":25,"end_line":27,"category":"bug","severity":"medium","content":"When `startTime` is empty or malformed, `parseInt('', 10)` returns `NaN`, all range checks are false, and the function silently returns `'night_check'` — an incorrect session classification that masks data errors. Validate the input (throw on `Number.isNaN(hour)`) instead of falling through to a misleading default, or make the default an explicit 'unknown' session.","suggestion_code":" if (Number.isNaN(hour)) {\n throw new Error(`Invalid startTime: ${startTime}`);\n }\n if (hour < 20) return 'evening_study';\n return 'night_check';\n}","existing_code":" if (hour < 20) return 'evening_study';\n return 'night_check';\n}"}
{"path":"apps/server/src/attendance/attendance-time.ts","start_line":8,"end_line":9,"category":"maintainability","severity":"low","content":"Timezone handling is inconsistent within this module: `getCourseClock` derives date/minutes from the process's local timezone (`dayjs(date)`), while `shiftDate` forces UTC. These only agree because the deployment assumes Asia/Shanghai (per the comment in common/dayjs.ts); if the server ever runs with a different `TZ`, `getCourseClock` will return a different calendar date than `shiftDate`/`lessonDate`, causing off-by-one-day attendance errors. Consider making both UTC-based or passing an explicit timezone to keep date-only arithmetic consistent.","suggestion_code":"export function getCourseClock(date: Date): { date: string; minutes: number } {\n const d = dayjs.utc(date);","existing_code":"export function getCourseClock(date: Date): { date: string; minutes: number } {\n const d = dayjs(date);"}
{"path":"apps/server/src/attendance/attendance-time.ts","start_line":20,"end_line":22,"category":"maintainability","severity":"low","content":"The session names (`'morning_reading'`, `'morning'`, `'afternoon'`, `'evening_study'`, `'night_check'`) are business keys hardcoded in a stringly-typed way. They are used for lookups elsewhere and would benefit from being shared constants (or a union type) to avoid typos and to keep the hour boundaries documented.","suggestion_code":null,"existing_code":"export function mapLessonScheduleTimeToSession(startTime: string): string {\n const hour = parseInt(startTime.slice(0, 2), 10);\n if (hour < 8) return 'morning_reading';"}
{"path":"apps/server/src/attendance/attendance-report.service.ts","start_line":179,"end_line":181,"category":"bug","severity":"high","content":"The \"consecutive\" absence/late detection does not actually check that dates are consecutive calendar days. The query only returns records with status IN ('absent','late'), so any intervening present/leave/pending record is invisible — a student absent Mon, present Tue, absent Wed will be counted as count=2 \"consecutive\" absences. Additionally, multiple sessions on the same day (morning/afternoon) are counted as separate days, and the lastDate update (`r.attendanceDate > current.lastDate`) never fires because the list is already sorted by date DESC. To correctly detect consecutive absences, group records per student per day (dedupe by date) and verify date difference === 1 day between consecutive entries.","suggestion_code":null,"existing_code":" if (current && current.studentId === r.studentId && current.type === status) {\n current.count++;\n if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;"}
{"path":"apps/server/src/attendance/attendance-report.service.ts","start_line":122,"end_line":125,"category":"bug","severity":"medium","content":"Statuses other than the four handled here (e.g., 'pending', which is a valid status elsewhere in this module — see attendance-query.service.ts getSummary which counts it) are silently dropped: they contribute to neither `total` nor the rates, so the report statistics are skewed whenever pending/unhandled records exist. Add a `pending` field (or otherwise include unknown statuses) in the aggregation and in `total`.","suggestion_code":null,"existing_code":" if (row.status === 'present') entry.present += count;\n else if (row.status === 'absent') entry.absent += count;\n else if (row.status === 'late') entry.late += count;\n else if (row.status === 'leave') entry.leave += count;"}
{"path":"apps/server/src/attendance/attendance-report.service.ts","start_line":143,"end_line":145,"category":"bug","severity":"low","content":"Timezone handling mixes server-local time arithmetic with a fixed UTC+8 formatting offset: `new Date()`/`setDate` operate in the server's local timezone while `dayjs(cutoff).utcOffset(8)` displays the result in UTC+8. If the server's local timezone is not UTC+8 (and its calendar date differs from Beijing's at the moment of execution), the cutoff date can be off by one day. Since the project standardizes on UTC+8 (see common/dayjs.ts), compute the cutoff directly from a UTC+8 now, e.g. `dayjs().utcOffset(8).subtract(days, 'day').format('YYYY-MM-DD')`.","suggestion_code":null,"existing_code":" const cutoff = new Date();\n cutoff.setDate(cutoff.getDate() - days);\n const cutoffStr = dayjs(cutoff).utcOffset(8).format('YYYY-MM-DD');"}
{"path":"apps/server/src/attendance/attendance-report.service.ts","start_line":37,"end_line":42,"category":"maintainability","severity":"low","content":"The classId/accessibleClassIds access-control filter and the dateFrom/dateTo filter blocks are duplicated (with minor variations) across findAllForExport, getReport, and getAlerts. Consider extracting a private helper that builds a base QueryBuilder with these filters applied, to avoid divergence between the three methods.","suggestion_code":null,"existing_code":" if (query.classId) {\n qb.andWhere('ar.classId = :classId', { classId: query.classId });\n } else if (accessibleClassIds) {\n if (accessibleClassIds.length === 0) return [];\n qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });\n }"}
{"path":"apps/server/src/attendance/attendance-records.controller.ts","start_line":124,"end_line":126,"category":"maintainability","severity":"low","content":"Uses loose equality `== null` / `!= null`. Per project rules, strict equality is required. Since `record.classId` is an optional `number`, check explicitly for null/undefined (e.g. `record.classId == null` should be `record.classId === null || record.classId === undefined`, or use `record.classId != null` → strict equivalent). The same pattern also appears later in `update`/`remove` (`existing.classId == null`).","suggestion_code":" if (!canManageAll && dto.records.some((record) => record.classId === null || record.classId === undefined)) {\n throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');\n }","existing_code":" if (!canManageAll && dto.records.some((record) => record.classId == null)) {\n throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');\n }"}
{"path":"apps/server/src/attendance/attendance-records.controller.ts","start_line":86,"end_line":89,"category":"bug","severity":"medium","content":"When `importFromDingTalk` returns `success: false` with an empty `errors` array, this branch pushes nothing and `continue`s, so the failure is silently swallowed: the audit log reports status `success` and the response `errors` is empty even though nothing was refreshed. Make the failure branch always surface an error message.","suggestion_code":" if (!importResult.success) {\n errors.push(\n importResult.errors.length > 0\n ? importResult.errors.join('')\n : `排课 ${schedule.id} 刷新失败(无详细错误)`,\n );\n continue;\n }\n if (importResult.errors.length > 0) {\n errors.push(...importResult.errors);\n continue;\n }","existing_code":" if (!importResult.success || importResult.errors.length > 0) {\n errors.push(...importResult.errors);\n continue;\n }"}
{"path":"apps/server/src/attendance/attendance-records.controller.ts","start_line":214,"end_line":217,"category":"bug","severity":"low","content":"`encodeURIComponent()` output is placed in the plain `filename` parameter, which is NOT percent-decoded by most browsers — exported files will be saved with a garbled/percent-encoded name (e.g. `%E8%80%83%E5%8B%A4...xlsx`). Use an ASCII fallback filename plus the RFC 5987 `filename*=UTF-8''...` parameter for the Chinese name.","suggestion_code":" res.setHeader(\n 'Content-Disposition',\n `attachment; filename=attendance-report.xlsx; filename*=UTF-8''${encodeURIComponent(`考勤统计报表-${dateRange}`)}.xlsx`,\n );","existing_code":" res.setHeader(\n 'Content-Disposition',\n `attachment; filename=${encodeURIComponent(`考勤统计报表-${dateRange}`)}.xlsx`,\n );"}
{"path":"apps/server/src/attendance/attendance-records.controller.ts","start_line":251,"end_line":272,"category":"performance","severity":"low","content":"Each id in `dto.ids` (up to 200) triggers 23 sequential DB awaits, so this endpoint performs up to ~600 sequential round trips (N+1). The per-item work is independent and each item already isolates its own errors, so it can be parallelized with `Promise.all` while keeping the same per-item counting semantics.","suggestion_code":" await Promise.all(\n dto.ids.map(async (id) => {\n try {\n const existing = await this.service.findAttendanceRecord(id);\n if (existing.classId == null && !this.canManageAllAttendance(req)) {\n throw new ForbiddenException('无权修改未关联班级的考勤记录');\n }\n if (existing.classId != null) await this.assertClassAccess(req, existing.classId);\n await this.service.update(id, { status: dto.status, remark: dto.remark });\n updated += 1;\n } catch (error) {\n failedIds.push(id);\n if (\n error instanceof BadRequestException ||\n error instanceof NotFoundException ||\n error instanceof ForbiddenException\n ) {\n return;\n }\n systemFailed += 1;\n }\n }),\n );","existing_code":" for (const id of dto.ids) {\n try {\n const existing = await this.service.findAttendanceRecord(id);\n if (existing.classId == null && !this.canManageAllAttendance(req)) {\n throw new ForbiddenException('无权修改未关联班级的考勤记录');\n }\n if (existing.classId != null) await this.assertClassAccess(req, existing.classId);\n await this.service.update(id, { status: dto.status, remark: dto.remark });\n updated += 1;\n } catch (error) {\n failedIds.push(id);\n // 业务失败(已结算/无权限等)与系统错误区分开,便于前端给出准确提示\n if (\n error instanceof BadRequestException ||\n error instanceof NotFoundException ||\n error instanceof ForbiddenException\n ) {\n continue;\n }\n systemFailed += 1;\n }\n }"}
{"path":"apps/server/src/attendance/attendance-records.controller.ts","start_line":253,"end_line":257,"category":"maintainability","severity":"low","content":"Same loose equality pattern (`== null`) as in `batchCreate`; the project rules require strict equality. The same pattern also appears in the `remove` handler below.","suggestion_code":" const existing = await this.service.findAttendanceRecord(id);\n if (existing.classId === null && !this.canManageAllAttendance(req)) {\n throw new ForbiddenException('无权修改未关联班级的考勤记录');\n }\n if (existing.classId !== null) await this.assertClassAccess(req, existing.classId);","existing_code":" const existing = await this.service.findAttendanceRecord(id);\n if (existing.classId == null && !this.canManageAllAttendance(req)) {\n throw new ForbiddenException('无权修改未关联班级的考勤记录');\n }\n if (existing.classId != null) await this.assertClassAccess(req, existing.classId);"}
{"path":"apps/server/src/attendance/dto/dingtalk-import.dto.ts","start_line":16,"end_line":17,"category":"bug","severity":"medium","content":"No validation ensures `end` is not earlier than `start`. An inverted date range (e.g. start=2026-08-10, end=2026-08-01) would pass validation and could cause the import to query an empty/incorrect range or behave unexpectedly downstream. Add a custom cross-field validator (e.g. @ValidateIf + @IsDateString on a check, or a class-level @ValidatorConstraint) or at minimum guard this in the service.","suggestion_code":null,"existing_code":" @IsDateString()\n end?: string;"}
{"path":"apps/server/src/attendance/dto/dingtalk-import.dto.ts","start_line":10,"end_line":12,"category":"bug","severity":"low","content":"`IsDateString()` accepts full ISO 8601 timestamps (e.g. '2026-08-09T12:00:00.000Z'), not just the documented YYYY-MM-DD format. If downstream code treats these as date-only, timezone shifts can silently move the date across days. Consider a stricter regex/format check or explicit normalization if the range boundaries must be day-granular.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsDateString()\n start?: string;"}
{"path":"apps/server/src/attendance/attendance.controller-base.ts","start_line":41,"end_line":43,"category":"security","severity":"medium","content":"Security concern: `CaslAction.Update` on `SubjectName.Class` (legacy class:edit) is treated as 'manage all attendance'. Combined with AttendanceService.getAccessibleClassIds/assertClassAccess (canManageAll=true returns undefined = ALL classes and bypasses class-teacher scoping), any user who can merely edit a class gets unrestricted access to every class's attendance records, bypassing the classTeacher scoping used elsewhere. Since this is a legacy fallback, recommend removing it or narrowing it to a dedicated permission (e.g., a Manage-on-Attendance check or explicit attendance:manage-all permission code) so class:edit no longer grants cross-scope attendance access.","suggestion_code":null,"existing_code":" this.authz.can(req, CaslAction.Manage, SubjectName.Attendance) ||\n // Legacy: class:edit grants broad attendance access for teacher scoping\n this.authz.can(req, CaslAction.Update, SubjectName.Class)"}
{"path":"apps/server/src/attendance/attendance.controller-base.ts","start_line":36,"end_line":36,"category":"maintainability","severity":"low","content":"Hardcoded business value: the UTC+8 offset is a magic number. While common/dayjs.ts documents the fixed Asia/Shanghai convention, the literal `8` here is opaque to readers and can drift out of sync if the convention ever changes. Suggest extracting it to a named constant (e.g., CHINA_UTC_OFFSET = 8) or using a timezone-aware helper so the intent is explicit.","suggestion_code":" const CHINA_UTC_OFFSET = 8;\n return dayjs().utcOffset(CHINA_UTC_OFFSET).format('YYYY-MM-DD');","existing_code":" return dayjs().utcOffset(8).format('YYYY-MM-DD');"}
{"path":"apps/server/src/attendance/dto/attendance.dto.ts","start_line":35,"end_line":37,"category":"bug","severity":"medium","content":"startTime/endTime are only format-validated (HH:mm); there is no cross-field check that endTime is after startTime. A period such as \"22:00\" -> \"08:00\" or a reversed pair passes validation and can be persisted, leading to incorrect schedule-generation logic downstream. Consider a custom validator (e.g., @Validate(EndTimeAfterStartTimeConstraint)) or a service-level check.","suggestion_code":null,"existing_code":" @IsString()\n @Matches(/^([01]\\d|2[0-3]):[0-5]\\d$/)\n endTime: string;"}
{"path":"apps/server/src/attendance/dto/attendance.dto.ts","start_line":107,"end_line":113,"category":"bug","severity":"medium","content":"dateFrom/dateTo have no ordering validation. The same pattern is repeated in QueryAttendanceRecordsDto, AttendanceReportQueryDto, QueryDingRawDto and GenerateAttendanceFromSchedulesDto; a reversed range (dateFrom > dateTo) passes validation and silently yields empty or incorrect query results. Add a cross-field validator or check the ordering in the service layer.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsDateString()\n dateFrom?: string;\n\n @IsOptional()\n @IsDateString()\n dateTo?: string;"}
{"path":"apps/server/src/attendance/dto/attendance.dto.ts","start_line":74,"end_line":77,"category":"maintainability","severity":"low","content":"Business enum values are duplicated as inline string arrays across multiple DTOs: session values appear in AttendanceRecordItem, and status values appear in AttendanceRecordItem, UpdateAttendanceRecordDto, BatchUpdateAttendanceStatusDto and QueryAttendanceRecordsDto. Extract them into shared constants or TS enums (e.g., export const ATTENDANCE_STATUSES = ['present','late','absent','leave'] as const) so there is a single source of truth and no risk of drift when a status/session is added.","suggestion_code":null,"existing_code":" @IsString()\n @IsIn(['present', 'late', 'absent', 'leave'])\n @IsNotEmpty()\n status: string;"}
{"path":"apps/server/src/attendance/dto/attendance.dto.ts","start_line":89,"end_line":93,"category":"performance","severity":"low","content":"The records array has no upper bound, unlike BatchUpdateAttendanceStatusDto which caps ids at 200. A client can submit an arbitrarily large payload, causing high CPU/memory usage during transformation and per-item nested validation (same applies to SaveAttendancePeriodConfigsDto.periods). Consider adding @ArrayMaxSize(...) to bound the batch size.","suggestion_code":null,"existing_code":" @IsArray()\n @ArrayNotEmpty()\n @ValidateNested({ each: true })\n @Type(() => AttendanceRecordItem)\n records: AttendanceRecordItem[];"}
{"path":"apps/server/src/attendance/attendance-lesson.service.ts","start_line":157,"end_line":157,"category":"bug","severity":"medium","content":"Unsound non-null assertion: `studentsById` is built from `getClassStudentsForLesson(...)` which filters students active on the lesson date, but `existingRecords` may contain records for students who have since left the class / are no longer active on that date (roster changed after the session was created). In that case `studentsById.get(...)` returns `undefined` and `record.student` is set to `undefined`, losing the `student` relation in the saved entity and in the returned payload (controllers serializing `record.student.name` will break), and possibly nulling the FK on save. Fall back to the existing value instead of asserting non-null.","suggestion_code":" const student = studentsById.get(record.studentId);\n if (student) record.student = student;","existing_code":" record.student = studentsById.get(record.studentId)!;"}
{"path":"apps/server/src/attendance/attendance-lesson.service.ts","start_line":246,"end_line":254,"category":"bug","severity":"medium","content":"Concurrency gap in the first-pull fallback: this method mutates `AttendanceSession`/`AttendanceRecord` without the `sessionMutex` that `completeLessonAttendance` uses, and when two requests race to create the session, the loser catches `ER_DUP_ENTRY`, finds the winner's session and returns its records *without honoring the current request's `finalize` flag*. A concurrent finalize request can therefore silently succeed while returning an `in_progress` session with `pending` records — the finalize is effectively dropped. Consider serializing this method (e.g., reuse `sessionMutex` keyed by `scheduleId:lessonDate`) and re-applying the resolve/finalize logic on the fallback path instead of returning early.","suggestion_code":null,"existing_code":" if (existing) {\n session = existing;\n const existingRecords = await recordRepo.find({\n where: { attendanceSessionId: session.id },\n relations: ['student'],\n order: { studentId: 'ASC' },\n });\n return { schedule, session, records: await attachAttendanceDeviceMappings(existingRecords, this.attendanceDeviceRepo, schedule.classId) };\n }"}
{"path":"apps/server/src/attendance/attendance-lesson.service.ts","start_line":37,"end_line":42,"category":"maintainability","severity":"low","content":"Dead code: `classRepo`, `studentRepo`, `studentDingMappingRepo`, and `classTeacherRepo` are injected into the constructor but never referenced anywhere in this service. Remove them to avoid unused dependencies (and unnecessary injection overhead).","suggestion_code":null,"existing_code":" @InjectRepository(Class) private classRepo: Repository<Class>,\n @InjectRepository(Student) private studentRepo: Repository<Student>,\n @InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,\n @InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,\n @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository<StudentDingMapping>,\n @InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,"}
{"path":"apps/server/src/attendance/attendance-lesson.service.ts","start_line":358,"end_line":358,"category":"maintainability","severity":"low","content":"Leftover placeholder: this trailing section header at the end of the file has no following implementation — either the \"batch create attendance records\" feature is missing or this is dead scaffolding. Implement it or remove the header.","suggestion_code":null,"existing_code":" // ── Batch create attendance records ──"}
{"path":"apps/server/src/attendance/attendance.service.ts","start_line":62,"end_line":62,"category":"maintainability","severity":"medium","content":"This `sessionMutex` field is declared but never referenced anywhere in this file, making it dead code (the other attendance services — attendance-lesson.service.ts and attendance-record-mutation.service.ts — each construct and use their own mutex via `runExclusive`). Either remove the field, or if serialization of session operations is intended, wire it into the write methods (e.g. `batchCreate` / `generateFromSchedules` / `completeLessonAttendance`) which currently delegate without any locking.","suggestion_code":null,"existing_code":" private sessionMutex = new SessionMutex();"}
{"path":"apps/server/src/attendance/attendance.service.ts","start_line":163,"end_line":163,"category":"bug","severity":"low","content":"`query.limit` is caller-controlled with no validation or upper bound. A negative value (e.g. `-1`) produces invalid/undefined LIMIT behavior, `0` silently returns nothing, and a huge value can pull an unbounded result set into memory. Consider clamping, e.g. `Math.min(Math.max(query.limit ?? 30, 1), 100)`.","suggestion_code":null,"existing_code":" .limit(query.limit ?? 30)"}
{"path":"apps/server/src/attendance/attendance.controller.ts","start_line":107,"end_line":112,"category":"bug","severity":"medium","content":"`importFromDingTalk` is a global singleton import (guarded by `this.isRunning` in `AttendanceImportService`) and can throw synchronously (e.g. `'An import is already in progress'`) instead of returning a result object. The current code only handles the returned `{ success: false, errors }` shape, so a thrown error propagates as an unhandled 500 with a raw English message. Wrap the call in try/catch and convert failures into a user-friendly `BadRequestException` (the lesson-attendance-sync service does exactly this).","suggestion_code":" let importResult;\n try {\n importResult = await this.importService.importFromDingTalk({\n ...importRange,\n userIds: importClassIds,\n autoMatch: true,\n userId: req.user.id,\n });\n } catch (error) {\n throw new BadRequestException(\n (error as Error)?.message || '钉钉考勤拉取失败,请稍后重试',\n );\n }","existing_code":" const importResult = await this.importService.importFromDingTalk({\n ...importRange,\n userIds: importClassIds,\n autoMatch: true,\n userId: req.user.id,\n });"}
{"path":"apps/server/src/attendance/attendance.controller.ts","start_line":116,"end_line":120,"category":"performance","severity":"medium","content":"When `schedule.session` already exists (lesson already pulled), this endpoint still re-runs the full DingTalk import and then calls `createLessonAttendanceFromDingTalk`, which for existing sessions internally re-fetches DingTalk raw data again and refreshes records — while the log below labels this case as '查看已拉取课程考勤' (view-only). This duplicates external API calls, re-acquires the global import lock (and may fail with 'An import is already in progress' or block other imports), and makes `imported`/`matched` in the log misleading. Consider short-circuiting: if `schedule.session` exists, return the existing lesson data directly without re-importing.","suggestion_code":null,"existing_code":" const result = await this.service.createLessonAttendanceFromDingTalk(\n scheduleId,\n dto.date,\n req.user.id,\n );"}
{"path":"apps/server/src/attendance/attendance.controller.ts","start_line":83,"end_line":85,"category":"security","severity":"low","content":"The class-access authorization check (`assertClassAccess`) is performed only AFTER the full lesson data has been fetched from the database. An unauthorized user can therefore distinguish '排课记录不存在' from the permission error (schedule existence disclosure) and force DB queries before being rejected. Consider asserting access as soon as the schedule/class is resolved, before returning lesson data.","suggestion_code":null,"existing_code":" const result = await this.service.getLessonAttendance(scheduleId, query.date);\n await this.assertClassAccess(req, result.schedule.classId!);\n return result;"}
{"path":"apps/server/src/auth/auth.module.ts","start_line":20,"end_line":20,"category":"security","severity":"high","content":"Security: the JWT signing secret falls back to a hardcoded value `'dorm-billing-jwt-secret-key-2024'` when the `JWT_SECRET` env var is missing. This exact value is publicly documented in the repo's technical doc/.env.example, so if the env var is ever unset (e.g., misconfigured deployment, local/test env), anyone who knows the source can forge valid JWTs and impersonate any user (including super admins). It also silently degrades security instead of failing fast. Recommend removing the fallback and throwing when `JWT_SECRET` is absent (e.g., validate at startup), and ideally validating the secret length/strength. Same fallback also appears in jwt.strategy.ts and should be aligned.","suggestion_code":"secret: (() => {\n const secret = config.get<string>('JWT_SECRET');\n if (!secret) {\n throw new Error('JWT_SECRET environment variable is required but not set');\n }\n return secret;\n })(),","existing_code":"secret: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'),"}
{"path":"apps/server/src/auth/auth.module.ts","start_line":21,"end_line":21,"category":"maintainability","severity":"low","content":"Hardcoding: `'4h'` is a business/security configuration default inlined here. This is only a minor concern versus the secret fallback, but the expiration policy is better sourced from a validated config value rather than buried as a magic string; consider at least centralizing it in the config schema (e.g., Joi validation) so misconfigurations are caught.","suggestion_code":null,"existing_code":"signOptions: { expiresIn: config.get('JWT_EXPIRES_IN', '4h') },"}
{"path":"apps/server/src/attendance/lesson-attendance-sync.service.ts","start_line":35,"end_line":40,"category":"bug","severity":"medium","content":"No try/catch wraps the DingTalk import call. `AttendanceImportService.importFromDingTalk` throws a plain `Error('An import is already in progress')` when any other import is running (it uses a singleton `isRunning` guard), and this call site lets that generic Error propagate — Nest will turn it into an HTTP 500 with no user-friendly message. Since `syncLesson` is a per-lesson operation that may be triggered concurrently with a manual import, this is a realistic failure path. Catch the error here and rethrow as a `BadRequestException` with a clear message (e.g. '已有考勤导入任务在进行中,请稍后再试').","suggestion_code":null,"existing_code":" const imported = await this.importService.importFromDingTalk({\n ...dateRange,\n userIds,\n autoMatch: true,\n userId: actorId,\n });"}
{"path":"apps/server/src/attendance/lesson-attendance-sync.service.ts","start_line":25,"end_line":30,"category":"maintainability","severity":"low","content":"`schedule.classId!` uses a non-null assertion that silently trusts the return of `getLessonAttendance`. Although `getScheduleOccurrence` currently validates that `classId` is not null, that guard lives in a different service and the assertion bypasses it — if that validation is ever removed or the schedule shape changes, `getTeacherClassDingUserIds`/`importFromDingTalk` receive `undefined` and fail with an obscure error. Add an explicit null check here (e.g. `if (schedule.classId == null) throw new BadRequestException('该排课未关联班级');`) instead of the assertion.","suggestion_code":null,"existing_code":" const userIds = await this.attendanceService.getTeacherClassDingUserIds(\n actorId,\n schedule.classId!,\n canManageAll,\n lessonDate,\n );"}
{"path":"apps/server/src/auth/dto/auth.dto.ts","start_line":7,"end_line":9,"category":"security","severity":"high","content":"Password minimum length of 4 is far too weak for a credential (allows trivial brute-force, e.g., \"abcd\"). Also, without a @MaxLength bound, an attacker can submit an arbitrarily large password, forcing expensive hashing on the server (DoS vector). Recommend @MinLength(8) plus a reasonable @MaxLength, and ideally a complexity rule (e.g., @Matches for letter/digit mix).","suggestion_code":" @IsString()\n @MinLength(8)\n @MaxLength(128)\n password: string;","existing_code":" @IsString()\n @MinLength(4)\n password: string;"}
{"path":"apps/server/src/auth/dto/auth.dto.ts","start_line":4,"end_line":5,"category":"bug","severity":"medium","content":"username only uses @IsString(), which passes for an empty string. A blank username would be accepted by validation, leading to confusing login failures or insecure fallback handling downstream. Add @IsNotEmpty() (and optionally @MaxLength) to guard against empty/oversized values.","suggestion_code":" @IsString()\n @IsNotEmpty()\n @MaxLength(64)\n username: string;","existing_code":" @IsString()\n username: string;"}
{"path":"apps/server/src/auth/auth.controller.ts","start_line":29,"end_line":29,"category":"bug","severity":"medium","content":"Both logService.log calls sit directly in the request path without error isolation. If the operation-log write throws (e.g., DB unavailable), a successful login returns a 500, and in the catch block the original auth error e is swallowed by the logging error (throw e is never reached). Authentication outcome should not depend on audit-logging success - wrap the log calls in try/catch or fire-and-forget with .catch() so logging failures do not mask the real error.","suggestion_code":null,"existing_code":" await this.logService.log({"}
{"path":"apps/server/src/auth/auth.controller.ts","start_line":44,"end_line":44,"category":"maintainability","severity":"low","content":"The failure detail is always recorded as 'wrong password' regardless of the actual cause (account locked, user archived, or any other error). The service throws distinct messages for these cases, so this masking reduces the audit log's diagnostic value. Log the real error message/name in detail while keeping the client-facing error generic. The duplicated fallback string could also be extracted to a constant.","suggestion_code":null,"existing_code":" detail: e instanceof Error ? e.message || '密码错误' : '密码错误',"}
{"path":"apps/server/src/auth/auth.service.ts","start_line":11,"end_line":11,"category":"performance","severity":"medium","content":"This module-level Map is never pruned: entries are only removed on a successful login, so failed attempts below MAX_ATTEMPTS and entries whose lock has already expired accumulate forever, causing unbounded memory growth in a long-running process. It is also per-process in-memory state, so counters/locks are inconsistent (and easily bypassed) when the app runs multiple instances. Prefer a shared persistent store (e.g. Redis) or at least periodically purge expired entries (TTL-based cleanup).","suggestion_code":null,"existing_code":"const loginAttempts = new Map<string, { count: number; lockedUntil?: Date }>();"}
{"path":"apps/server/src/auth/auth.service.ts","start_line":89,"end_line":92,"category":"bug","severity":"medium","content":"After the 15-minute lock expires, `count` is never reset (it stays >= MAX_ATTEMPTS). A user then gets only one fresh chance: a single new failed attempt immediately increments count and re-locks for another 15 minutes, so the lock is effectively permanent unless the user logs in successfully. Additionally, after expiry `remaining = MAX_ATTEMPTS - count` becomes 0/negative, making the '还剩 N 次尝试机会' message misleading. Reset `count` (and clear `lockedUntil`) once the lock period has passed.","suggestion_code":null,"existing_code":" attempt.count++;\n if (attempt.count >= MAX_ATTEMPTS) {\n attempt.lockedUntil = new Date(Date.now() + LOCK_MINUTES * 60 * 1000);\n }"}
{"path":"apps/server/src/auth/auth.service.ts","start_line":68,"end_line":68,"category":"maintainability","severity":"low","content":"Business role identifiers ('超管', 'super_admin') are hardcoded inline. Extract them into constants/configuration so role naming/code changes don't require code edits and to avoid inconsistent spellings elsewhere in the codebase.","suggestion_code":null,"existing_code":" (role.name === '超管' || role.name === 'super_admin' || role.code === 'super_admin'),"}
{"path":"apps/server/src/auth/auth.service.ts","start_line":37,"end_line":40,"category":"security","severity":"low","content":"Timing-based username enumeration: when the username does not exist the code returns immediately without running `bcrypt.compare`, while an existing user triggers the comparison. The measurable response-time difference lets an attacker probe which usernames are registered. Perform a dummy bcrypt comparison against a fixed hash for non-existent users to equalize the timing.","suggestion_code":null,"existing_code":" if (!user) {\n this.recordFailedAttempt(attemptKey);\n throw new UnauthorizedException('用户名或密码错误');\n }"}
{"path":"apps/server/src/auth/auth.service.ts","start_line":96,"end_line":98,"category":"security","severity":"medium","content":"`validateUser` returns the full User entity, which includes `passwordHash`. If the returned object is attached to the request (e.g. by a JwtStrategy) or logged/serialized downstream, the password hash can leak. Restrict the query to the fields actually needed, e.g. `select: ['id', 'username', 'name', 'isArchived', 'lastLoginAt']`.","suggestion_code":null,"existing_code":" async validateUser(payload: { sub?: number }) {\n return this.userRepo.findOne({ where: { id: payload.sub } });\n }"}
{"path":"apps/server/src/auth/strategies/jwt.strategy.ts","start_line":30,"end_line":30,"category":"security","severity":"high","content":"Security: the JWT secret falls back to a hardcoded, publicly committed value ('dorm-billing-jwt-secret-key-2024'). If JWT_SECRET is not set in the environment, tokens are signed/verified with this known secret, letting anyone forge a JWT with an arbitrary `sub` and impersonate any user (full auth bypass). This is also a predictable, short key. Remove the default and fail fast at startup when JWT_SECRET is missing, e.g. use config.getOrThrow('JWT_SECRET').","suggestion_code":" secretOrKey: config.getOrThrow<string>('JWT_SECRET'),","existing_code":" secretOrKey: config.get('JWT_SECRET', 'dorm-billing-jwt-secret-key-2024'),"}
{"path":"apps/server/src/auth/strategies/jwt.strategy.ts","start_line":20,"end_line":22,"category":"security","severity":"medium","content":"Security: accepting the JWT via query string (?token=) on every request exposes credentials in access logs, browser history, referrer headers, and proxy caches. If the SSE fallback is truly required, it should be limited to SSE routes only (e.g., a dedicated narrow strategy for the SSE endpoint) and use a short-lived token; otherwise the token should be removed from the URL as soon as it is read so it isn't propagated further.","suggestion_code":null,"existing_code":" // 2. SSE fallback: query string ?token=\n (req: Request) => {\n const token = req?.query?.token;"}
{"path":"apps/server/src/auth/strategies/jwt.strategy.ts","start_line":50,"end_line":50,"category":"maintainability","severity":"low","content":"Maintainability/hardcoding: business role names/codes ('超管', 'super_admin') and the magic status value `role.status !== 1` are hardcoded here; these business constants should be centralized (constants/config) so they stay consistent across the codebase. Also `permission.code` may be undefined; consider guarding the `permissions.add(permission.code)` call.","suggestion_code":null,"existing_code":" if (role.name === '超管' || role.name === 'super_admin' || role.code === 'super_admin') {"}
{"path":"apps/server/src/attendance/attendance-settlement.service.ts","start_line":14,"end_line":14,"category":"maintainability","severity":"low","content":"Dead code: `courseTimeZone` is declared but never referenced anywhere in the class (the actual timezone handling is done via the hardcoded `utcOffset(8)` in `getCourseClock`). Either use this field as the single source of truth for the timezone offset or remove it to avoid confusion between the declared value and the hardcoded `8`.","suggestion_code":null,"existing_code":" private readonly courseTimeZone = 'Asia/Shanghai';"}
{"path":"apps/server/src/attendance/attendance-settlement.service.ts","start_line":33,"end_line":37,"category":"bug","severity":"medium","content":"The 35-minute stale-claim reset can reclaim a session that is still being legitimately settled. `updatedAt` is only written when the session status is claimed/flipped (import, leave-sync and finalize in `settleCandidate` do not touch the row), so a slow/large DingTalk import (or a multi-replica deployment where another instance is mid-flight) that runs longer than 35 minutes will have its session reset to `in_progress` and be claimed again by the next cron tick — causing duplicate concurrent settlement, repeated DingTalk pulls and record churn. Consider refreshing `updatedAt` as a heartbeat during long operations, using a lease/instance token in the claim, and making the threshold configurable rather than a magic number.","suggestion_code":null,"existing_code":" const staleClaimBefore = new Date(now.getTime() - 35 * 60 * 1000);\n await this.sessionRepo.update(\n { status: 'settling', updatedAt: LessThan(staleClaimBefore) },\n { status: 'in_progress' },\n );"}
{"path":"apps/server/src/attendance/attendance-settlement.service.ts","start_line":171,"end_line":176,"category":"bug","severity":"medium","content":"Any failure other than the 'no active students' case resets the session to `in_progress`, so the same candidate is retried on every cron tick (every minute) indefinitely — e.g. a persistent DingTalk auth failure, a teacher whose class-teacher assignment was removed, or '该班级学生尚未同步钉钉账号' will trigger unbounded retries, repeated external API calls and error-log spam with no backoff, retry cap, or dead-letter handling. Consider capping retries (e.g. mark the session failed/error after N attempts) or applying a backoff.","suggestion_code":null,"existing_code":" if (session) {\n await this.sessionRepo.update({ id: session.id, status: 'settling' }, { status: 'in_progress' });\n }\n this.logger.error(\n `课程${schedule.id} ${lessonDate}自动结算失败: ${error instanceof Error ? error.message : String(error)}`,\n );"}
{"path":"apps/server/src/attendance/attendance-settlement.service.ts","start_line":29,"end_line":30,"category":"bug","severity":"low","content":"The `running` guard is process-local only. If this service is ever deployed with multiple Node replicas, the cron will execute concurrently in every instance. The DB claim (`in_progress` → `settling`) protects already-created sessions, but candidates derived purely from schedules (no session yet, handled by the `ER_DUP_ENTRY` fallback in the lesson service) and the stale-claim reset path above can still race across instances, causing duplicate imports/settlements. Consider a distributed lock or DB advisory lock keyed on the cron job when scaling out.","suggestion_code":null,"existing_code":" if (this.running) return;\n this.running = true;"}
{"path":"apps/server/src/authorization/authorization.service.ts","start_line":28,"end_line":30,"category":"bug","severity":"medium","content":"A missing `req.user` is an authentication failure, not a permission denial — mapping it to `ForbiddenException` (403) conflates authN/authZ. Requests that reach this point without a trusted identity (e.g., JWT guard misconfigured or request built outside HTTP context) should receive `UnauthorizedException` (401). Consider throwing `UnauthorizedException` here instead.","suggestion_code":null,"existing_code":" if (!req.user) {\n throw new ForbiddenException('缺少可信授权身份');\n }"}
{"path":"apps/server/src/authorization/authorization.service.ts","start_line":88,"end_line":91,"category":"bug","severity":"medium","content":"`can()` is documented as a boolean convenience check, but because it delegates to `abilityForRequest()`, an unauthenticated request (missing `req.user`) makes it throw `ForbiddenException` instead of returning `false`. Several callers use it in boolean chains, e.g. `this.authz.can(req, ...) || this.authz.can(req, ...)` (classes.controller.ts, attendance.controller-base.ts) — under a missing user this would raise 403 rather than evaluate to false, unexpectedly failing open/closed logic. Either catch the missing-user case and return `false` (consistent with `canAbility`'s \"never throws\" contract), or document clearly that this shorthand can throw.","suggestion_code":null,"existing_code":" can(req: AuthorizationRequest, action: CaslAction, subject: AppSubject): boolean {\n const ability = this.abilityForRequest(req);\n return this.canAbility(ability, action, subject);\n }"}
{"path":"apps/server/src/authorization/guards/policies.guard.ts","start_line":67,"end_line":67,"category":"security","severity":"medium","content":"Async policy handlers are not awaited and will fail open. `execHandler` can return a `Promise` at runtime if a developer writes an async callback (`async (ability) => ...`) or an async `handle()` method (a common pattern, even though the interface is typed sync). `Array.prototype.every` coerces the returned `Promise` to truthy, so a rejected/false async check would incorrectly allow the request — an authorization bypass in a security-critical guard. Make `canActivate` async and await each handler result, e.g. `for (const h of handlers) { if (!(await this.execHandler(h, ability))) return false; } return true;`, or detect `Promise` in `execHandler` and await it.","suggestion_code":" for (const handler of handlers) {\n const result = this.execHandler(handler, ability);\n if (!(await result)) return false;\n }\n return true;","existing_code":" return handlers.every((handler) => this.execHandler(handler, ability));"}
{"path":"apps/server/src/authorization/interfaces.ts","start_line":26,"end_line":29,"category":"maintainability","severity":"low","content":"`AuthPrincipal` manually redeclares `permissions` and `isSuperAdmin` from `AuthenticatedUser`, creating two sources of truth for the same authorization-critical fields. If a field is added/renamed/retargeted on `AuthenticatedUser`, this subset can silently drift and the auth principal will no longer represent the real user. Consider deriving it instead, e.g.: `export type AuthPrincipal = Readonly<Pick<AuthenticatedUser, 'permissions' | 'isSuperAdmin'>>;` (with `Readonly` preserved for the immutability guarantee).","suggestion_code":null,"existing_code":"export type AuthPrincipal = {\n readonly permissions: readonly string[];\n readonly isSuperAdmin: boolean;\n};"}
{"path":"apps/server/src/authorization/casl-ability.factory.ts","start_line":43,"end_line":43,"category":"maintainability","severity":"low","content":"Duplicate permission codes in `user.permissions` (e.g., a user granted the same code via multiple roles) generate duplicate CASL rules in the raw rules array — both the exact-code rule and the domain-level rule are re-added for each occurrence. Consider iterating over a deduplicated set (`new Set(user.permissions ?? [])`) to keep the built ability's rule list lean and avoid redundant grants.","suggestion_code":"for (const code of new Set(user.permissions ?? [])) {","existing_code":"for (const code of user.permissions ?? []) {"}
{"path":"apps/server/src/authorization/casl-ability.factory.ts","start_line":37,"end_line":37,"category":"maintainability","severity":"low","content":"`build({ detectSubjectType })` is duplicated in both the super-admin branch and the normal path. Since `detectSubjectType` is already the CASL default for string subjects, this call can be hoisted to a single exit point (e.g., assign the builder result once and return it after the branch), which also keeps the two branches symmetric and easier to maintain.","suggestion_code":null,"existing_code":"return build({ detectSubjectType });"}
{"path":"apps/server/src/bills/dto/bill.dto.ts","start_line":23,"end_line":24,"category":"maintainability","severity":"medium","content":"The allowed status values are hardcoded twice: once in the `@IsIn` decorator array and again in the TypeScript union type. These two lists can silently drift apart (e.g., a future change updates only one). Define a single `as const` array and derive both the validator and the type from it.","suggestion_code":"export const BILL_STATUSES = ['unpaid', 'partially_paid', 'paid'] as const;\nexport type BillStatus = (typeof BILL_STATUSES)[number];\n\nexport class UpdateBillStatusDto {\n @IsIn(BILL_STATUSES)\n status: BillStatus;\n}","existing_code":"@IsIn(['unpaid', 'partially_paid', 'paid'])\nstatus: 'unpaid' | 'partially_paid' | 'paid';"}
{"path":"apps/server/src/bills/dto/bill.dto.ts","start_line":4,"end_line":7,"category":"maintainability","severity":"low","content":"The `operationId` field (same name, same regex `/^[\\w-]{8,64}$/`) is duplicated between `GenerateBillsDto` and `CancelBillDto`. Extract the pattern into a shared constant (e.g., `export const OPERATION_ID_PATTERN = /^[\\w-]{8,64}$/;`) and reuse it in both DTOs to keep validation rules consistent in a single place.","suggestion_code":"export const OPERATION_ID_PATTERN = /^[\\w-]{8,64}$/;\n\n@IsOptional()\n@IsString()\n@Matches(OPERATION_ID_PATTERN)\noperationId?: string;","existing_code":"@IsOptional()\n@IsString()\n@Matches(/^[\\w-]{8,64}$/)\noperationId?: string;"}
{"path":"apps/server/src/bills/dto/bill.dto.ts","start_line":13,"end_line":19,"category":"other","severity":"low","content":"`periodStart`/`periodEnd` are accepted as arbitrary strings with no format check, unlike `billingMonth` which is validated as `YYYY-MM`. If these are meant to be ISO date strings or `YYYY-MM-DD`, an unvalidated value will fail later at the DB/service layer with a confusing error. Add a matching `@Matches`/`@IsDateString` decorator (or document the expected format) so invalid input is rejected at the DTO boundary.","suggestion_code":null,"existing_code":"@IsOptional()\n@IsString()\nperiodStart?: string;\n\n@IsOptional()\n@IsString()\nperiodEnd?: string;"}
{"path":"apps/server/src/bills/bills.controller.ts","start_line":122,"end_line":123,"category":"bug","severity":"high","content":"Route shadowing bug: `@Put('batch/status')` is declared after `@Put(':id/status')`, so a request `PUT /bills/batch/status` is matched by the `:id/status` route first with `id='batch'`, and `ParseIntPipe` throws a 400 Bad Request — `batchUpdateStatus` is effectively unreachable via HTTP. Move the `batch/status` route above `:id/status` (static path before param path) to fix.","suggestion_code":null,"existing_code":" @Put('batch/status')\n @RequirePermission('bill:confirm')"}
{"path":"apps/server/src/bills/bills.controller.ts","start_line":59,"end_line":60,"category":"performance","severity":"medium","content":"Notification dispatch performs one `findOne` query per bill inside a loop (N+1), and this same loop logic is duplicated across `generateBills`, `updateStatus` and `batchUpdateStatus`. Fetch all students in a single query using `In(bills.map(b => b.studentId))` and dispatch notifications concurrently (e.g. `Promise.all`), or extract a private helper method to avoid the duplication.","suggestion_code":null,"existing_code":" for (const bill of result.bills) {\n const student = await this.studentRepo.findOne({ where: { id: bill.studentId } });"}
{"path":"apps/server/src/bills/bills.controller.ts","start_line":61,"end_line":63,"category":"bug","severity":"medium","content":"`void this.notificationsService.create(...)` discards the returned promise, so an async rejection (e.g. a DB failure inside the notifications service) becomes an unhandled promise rejection — the surrounding try/catch cannot catch it. Append `.catch(() => {})` (or `await` it inside the try) so failures are intentionally swallowed. The same pattern appears in `generateBills` and `batchUpdateStatus`.","suggestion_code":null,"existing_code":" if (student?.userId) {\n void this.notificationsService.create({\n recipientIds: [student.userId],"}
{"path":"apps/server/src/bills/bills.controller.ts","start_line":204,"end_line":206,"category":"maintainability","severity":"low","content":"`@Res()` without `{ passthrough: true }` switches this handler into library-specific response mode, so Nest will not send the value returned by `exportService.exportExcel` — the service must fully write the response itself. Also, since Nest always injects the response for `@Res()`, the optional `res?: Response` and the `res!` non-null assertion are misleading; drop them (or use `@Res({ passthrough: true })` if the service returns a sendable value).","suggestion_code":null,"existing_code":" @Query('status') status?: string,\n @Res() res?: Response,\n ) {"}
{"path":"apps/server/src/bills/bills.controller.ts","start_line":189,"end_line":190,"category":"bug","severity":"low","content":"Unlike `batchPurge`, `batchRemove` does not guard against a missing/empty `ids` payload; if the client omits `ids`, `body.ids.join(',')` throws an unhandled 500. Use `body.ids || []` here (and in `batchUpdateStatus`) for consistency and safety.","suggestion_code":null,"existing_code":" async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {\n const result = await this.service.batchRemove(body.ids);"}
{"path":"apps/server/src/authorization/casl.constants.ts","start_line":139,"end_line":142,"category":"maintainability","severity":"medium","content":"The resource→subject map is missing several resources that still have ACTIVE preset permissions: `teacher` (`teacher:view`, `teacher:edit`), `teacher-workspace` (`teacher-workspace:view`), `wallet` (`wallet:view`, `wallet:edit`) and `archive` (`archive:purge`). For these codes both `mapPermissionCode` and `isKnownPermissionCode` silently return `null`/`false`, so no domain-level ability is ever granted for them — a holder of `wallet:edit` (Update semantics) gets no `Update` ability on any subject, and `isKnownPermissionCode('teacher:view')` reports a defined preset permission as \"unknown\". If these entities genuinely don't need data-scoping, document that explicitly; otherwise add the missing mappings (e.g. `teacher` → a `Teacher` subject) to keep this table in sync with the permission catalog and avoid silent denial of domain abilities.","suggestion_code":null,"existing_code":" case 'ai':\n return SubjectName.AiConfig;\n default:\n return null;"}
{"path":"apps/server/src/authorization/casl.constants.ts","start_line":91,"end_line":92,"category":"bug","severity":"low","content":"`permissionToSubject` matches resource segments case-sensitively. A permission code whose resource segment differs in case (e.g. `Student:view` instead of `student:view`, or any future code with mixed case) silently falls through to `null`, so the user would hold the exact code (layer 1) but never receive the corresponding domain-level ability (layer 2), with no warning. Consider normalizing the resource (e.g. `resource.toLowerCase()`) before the switch to make the mapping robust.","suggestion_code":null,"existing_code":"function permissionToSubject(resource: string): SubjectName | null {\n switch (resource) {"}
{"path":"apps/server/src/authorization/casl.constants.ts","start_line":172,"end_line":175,"category":"maintainability","severity":"low","content":"`mapPermissionCode` and `isKnownPermissionCode` each re-parse `code.split(':')` and both depend on the same large hand-maintained switch, which must stay in sync with `SubjectName` and the permission catalog. Consider defining a single lookup table (e.g. `Record<string, SubjectName>`) and deriving `permissionToSubject`/`isKnownPermissionCode` from it to eliminate duplicated parsing and reduce the risk of the two functions diverging.","suggestion_code":null,"existing_code":"export function isKnownPermissionCode(code: string): boolean {\n const [resource] = code.split(':');\n return permissionToSubject(resource ?? '') !== null;\n}"}
{"path":"apps/server/src/bills/bills-export.service.ts","start_line":33,"end_line":34,"category":"bug","severity":"medium","content":"The controller/audit log exposes periodStart/periodEnd as a period range (\"周期X~Y\"), but exact `=` comparison only matches bills whose period boundaries exactly equal the filter values. Bills whose billing period falls inside the selected range but does not exactly equal it will be silently omitted. Use range semantics (`>=` / `<=`) for a true date-range filter.","suggestion_code":" if (query.periodStart) qb.andWhere('b.periodStart >= :ps', { ps: query.periodStart });\n if (query.periodEnd) qb.andWhere('b.periodEnd <= :pe', { pe: query.periodEnd });","existing_code":" if (query.periodStart) qb.andWhere('b.periodStart = :ps', { ps: query.periodStart });\n if (query.periodEnd) qb.andWhere('b.periodEnd = :pe', { pe: query.periodEnd });"}
{"path":"apps/server/src/bills/bills-export.service.ts","start_line":37,"end_line":37,"category":"bug","severity":"medium","content":"exportExcel has no error handling. Because `workbook.xlsx.write(res)` streams directly to the already-answered response, any error from `qb.getMany()` or mid-stream write leaves the client with a truncated/corrupt file (headers already sent), and the promise rejection can hang the request. Safer approach: build the workbook fully in memory (`const buffer = await workbook.xlsx.writeBuffer()`), then set headers and send the buffer inside a try/catch.","suggestion_code":" try {\n const bills = await qb.getMany();","existing_code":" const bills = await qb.getMany();"}
{"path":"apps/server/src/bills/bills-export.service.ts","start_line":140,"end_line":141,"category":"bug","severity":"medium","content":"Once `doc.pipe(res)` is called, the response headers are already sent. If anything throws while drawing the PDF (or the client disconnects mid-stream), there is no error handling/cleanup, so `doc.end()` is never called and the response stream hangs or the client receives a truncated PDF. Wrap the drawing code in try/catch and call `doc.end()` (or `res.destroy()`) on failure, or render to a buffer before piping.","suggestion_code":null,"existing_code":" res.setHeader('Content-Disposition', `attachment; filename=bill_${billId}.pdf`);\n doc.pipe(res);"}
{"path":"apps/server/src/bills/bills-export.service.ts","start_line":230,"end_line":232,"category":"bug","severity":"low","content":"Each column is drawn at the same absolute y with a fixed width, but `description` can be up to 200 chars and wraps to multiple lines inside its 180pt column. The next row's y is derived only from the last column's `doc.y` plus a fixed 0.8 line move, so multi-line descriptions overflow and overlap the following rows. Measure the tallest cell per row (e.g., `doc.heightOfString`) and advance `doc.y` by that height before drawing the next row.","suggestion_code":null,"existing_code":" for (const item of items) {\n const y = doc.y;\n x = 50;"}
{"path":"apps/server/src/bills/bills-export.service.ts","start_line":168,"end_line":171,"category":"maintainability","severity":"low","content":"The `statusMap` (unpaid/partially_paid/paid/cancelled) is duplicated in exportExcel and exportStudentPdf. Extract it to a module-level constant to keep the status label mapping in one place.","suggestion_code":null,"existing_code":" doc.font('Helvetica');\n }\n\n const statusMap: Record<string, string> = {"}
{"path":"apps/server/src/bills/bills-generation.service.ts","start_line":45,"end_line":46,"category":"bug","severity":"high","content":"`longTermOccupancies` is initialized as an empty array and never populated, so the `.filter((o) => o.stayType === 'long')` in the `roomIds` computation below always yields nothing. Consequently, rooms that only have long-term occupants (and no active RoomExpense within the period) are never added to `roomIds`, and the long-term rent block inside the per-room loop never runs for them — those students silently get no rent bill. Either populate this array (e.g. query long-term occupancies overlapping the period via `occRepo`) or remove the dead variable and its use in `roomIds`.","suggestion_code":null,"existing_code":" const longTermOccupancies: Occupancy[] = [];\n const roomExpMap = new Map<number, RoomExpense[]>();"}
{"path":"apps/server/src/bills/bills-generation.service.ts","start_line":31,"end_line":32,"category":"bug","severity":"medium","content":"The duplicate-bill guard is a classic check-then-act race: `existingBills` is queried before the transaction, and the `bills` table (bill.entity.ts) has no unique constraint on (student_id, period_start, period_end). Two concurrent requests for the same period can both pass this check and generate duplicate bills. Add a unique index on these columns, or re-check/lock inside the transaction, to make generation idempotent under concurrency.","suggestion_code":null,"existing_code":" const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });\n if (existingBills.length > 0) {"}
{"path":"apps/server/src/bills/bills-generation.service.ts","start_line":63,"end_line":64,"category":"performance","severity":"low","content":"Occupancy queries inside the per-room loop are awaited sequentially even though they are independent. When the period covers many rooms this serializes all DB round trips. Use `Promise.all` over the room ids (while preserving the per-room processing order) to cut latency.","suggestion_code":null,"existing_code":" for (const roomId of roomIds) {\n const expenses = roomExpMap.get(roomId) || [];"}
{"path":"apps/server/src/bills/bills-generation.service.ts","start_line":193,"end_line":194,"category":"performance","severity":"low","content":"BillItems are saved one-by-one with `await` inside a loop, issuing a separate INSERT per item inside the transaction. TypeORM can batch these with a single array save (`await manager.save(manager.create(BillItem, {...}), ...)`), reducing round trips and transaction hold time.","suggestion_code":null,"existing_code":" for (const item of items)\n await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));"}
{"path":"apps/server/src/bills/bills-generation.service.ts","start_line":239,"end_line":239,"category":"bug","severity":"low","content":"`${year}-${month}` can produce an unpadded string such as `2024-1`, which is not ISO and is parsed engine-dependently by dayjs/Date. If parsing fails, `daysInMonth()` returns NaN, making `days` and `total` NaN; since `NaN <= 0` is false, the `rent <= 0` guard below will not catch it and a NaN rent/item/bill total can be persisted. Zero-pad the month to keep an ISO `YYYY-MM` string.","suggestion_code":" const daysInMonth = dayjs.utc(`${year}-${String(month).padStart(2, '0')}`).daysInMonth();","existing_code":" const daysInMonth = dayjs.utc(`${year}-${month}`).daysInMonth();"}
{"path":"apps/server/src/bills/bills-generation.service.ts","start_line":195,"end_line":195,"category":"performance","severity":"low","content":"`personalExps.filter(...)` runs inside the per-student loop, making the personal-expense matching O(students × expenses). Build a `Map<number, PersonalExpense[]>` (indexed by studentId) once before the transaction and look it up here instead.","suggestion_code":null,"existing_code":" const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);"}
{"path":"apps/server/src/bills/bills.service.ts","start_line":252,"end_line":257,"category":"bug","severity":"medium","content":"归档账单remove/batchRemove只更新 Bill 状态,未像 cancel() 那样清空 PersonalExpense.billId。这会导致1) 个人费用仍指向已取消的账单数据不一致2) purge()/batchPurge() 会以「该账单仍关联个人费用,无法永久删除」为由永久拒绝删除这些账单,造成归档后无法清理的死数据。建议在 remove/batchRemove 中同样执行 personalExpRepo.update({ billId: id }, { billId: null }),并与状态更新放入同一事务。","suggestion_code":null,"existing_code":" await this.billRepo.update(id, {\n status: 'cancelled',\n outstandingAmount: 0,\n cancelReason: '归档未支付账单',\n cancelledAt: new Date(),\n });"}
{"path":"apps/server/src/bills/bills.service.ts","start_line":203,"end_line":205,"category":"bug","severity":"medium","content":"updateStatus以及 batchUpdateStatus没有拒绝对已取消账单的状态回改。remove() 归档时会把 outstandingAmount 置为 0之后调用 updateStatus(id, { status: 'paid' }) 会通过 assertStatusMatchesAmountsoutstanding <= 0把已取消账单重新标记为 paid产生 paidAmount=0 但 status='paid' 的脏数据。另外 dto.status 缺少白名单校验batchUpdateStatus 有),非法状态只会得到误导性的「状态与金额不一致」错误。建议:状态变更前若 bill.status === 'cancelled' 直接拒绝,并对目标 status 做合法值校验。","suggestion_code":null,"existing_code":" this.assertStatusMatchesAmounts(bill, dto.status);\n bill.status = dto.status;\n return this.billRepo.save(bill);"}
{"path":"apps/server/src/bills/bills.service.ts","start_line":32,"end_line":35,"category":"maintainability","severity":"low","content":"构造器中注入了 roomExpRepo、occRepo、roomRepo及对应实体 RoomExpense/Occupancy/Room 的 import但整个文件从未使用属于死代码/冗余依赖。建议移除这些注入和导入,避免误导维护者以为本服务管理这些实体。","suggestion_code":null,"existing_code":" @InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,\n @InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,\n @InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,\n @InjectRepository(Room) private roomRepo: Repository<Room>,"}
{"path":"apps/server/src/bills/bills.service.ts","start_line":348,"end_line":353,"category":"maintainability","severity":"low","content":"assertStatusMatchesAmounts 使用了嵌套三元表达式(项目规范禁止),且非法的 status 会落入最后一个分支,用户只会得到误导性的「状态必须与实付及未付金额一致」错误。建议改为 if/else 分支结构,并先对 status 做白名单校验(如 ['unpaid','partially_paid','paid'])。","suggestion_code":null,"existing_code":" const matches =\n status === 'paid'\n ? outstanding <= 0\n : status === 'partially_paid'\n ? paid > 0 && outstanding > 0\n : status === 'unpaid' && paid <= 0 && outstanding > 0;"}
{"path":"apps/server/src/bills/bills.service.ts","start_line":307,"end_line":310,"category":"performance","severity":"low","content":"batchPurge 在循环内为每条账单单独开启事务N 条账单就有 N 次事务提交/连接往返。建议将整个循环放入单个 dataSource.transaction 中,或对删除操作使用 Promise.all 并发执行,减少事务开销。","suggestion_code":null,"existing_code":" await this.dataSource.transaction(async (manager) => {\n await manager.delete(BillItem, { billId: bill.id });\n await manager.delete(Bill, bill.id);\n });"}
{"path":"apps/server/src/bills/bills.service.ts","start_line":115,"end_line":116,"category":"performance","severity":"low","content":"findAll 未做分页,随着账单量增长会一次性把全部账单(含 student 关联)加载进内存。建议增加 page/limit 或 offset 分页参数,并保证分页与排序一致。","suggestion_code":null,"existing_code":" const bills = await qb.getMany();\n return this.attachDepositInfo(bills);"}
{"path":"apps/server/src/auth/guards/permission.guard.ts","start_line":36,"end_line":36,"category":"security","severity":"medium","content":"`@Public()` is honored unconditionally before any permission/policy metadata is inspected. Because `getAllAndOverride` falls back to the class level, a controller-level `@Public()` will silently bypass a handler-level `@RequirePermission`/`@CheckPolicies` — precisely the controller-level-bypasses-handler-level scenario that the `@Authenticated` fallback below was explicitly designed to prevent. In a deny-by-default security guard this is a latent authorization-bypass footgun. Apply the same precedence rule as `@Authenticated` (only honor `@Public` when the handler itself carries no more-specific permission/policy declaration) or at minimum document the precedence contract.","suggestion_code":" const handlerHasAuthDeclaration =\n this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [context.getHandler()]).length > 0 ||\n this.reflector.getAllAndMerge<unknown[]>(CHECK_POLICIES_KEY, [context.getHandler()]).length > 0;\n if (isPublic && !handlerHasAuthDeclaration) return true;","existing_code":" if (isPublic) return true;"}
{"path":"apps/server/src/auth/guards/permission.guard.ts","start_line":88,"end_line":90,"category":"security","severity":"low","content":"`requiredPermissions` comes from `getAllAndMerge`, so class-level and handler-level `@RequirePermission` codes are flattened into one list and OR'd via `some()`. As a result, a controller-level `@RequirePermission('bill:view')` would satisfy a handler-level `@RequirePermission('bill:export-excel')` — a user holding only the weaker permission would pass, contradicting the exact-code guarantee stated in the header comment. The OR semantics is intentional (documented in `permission.decorator.ts`), but for a deny-by-default guard consider using `every()` (AND) for codes merged from different declaration levels, or explicitly document that cross-level codes are OR'd so future class-level declarations cannot silently widen handler-level access.","suggestion_code":null,"existing_code":" return requiredPermissions.some((code: string) =>\n ability.can(CaslAction.Access, permissionCodeSubject(code)),\n );"}
{"path":"apps/server/src/classes/classes.service.ts","start_line":120,"end_line":126,"category":"performance","severity":"medium","content":"The student-count aggregation runs a GROUP BY over the ENTIRE class_student table on every list request, ignoring the class filters (keyword/status/classType/accessible ids). For a large dataset this is an unnecessary full-table scan that gets executed even when the query only returns a few classes. Restrict the aggregation to the classes actually being returned, e.g. add `.where('cs.class_id IN (:...ids)', { ids: classes.map((c) => c.id) })` (or reuse the accessible-class ids when available).","suggestion_code":null,"existing_code":" const studentCounts: RawStudentCount[] = await this.classStudentRepo\n .createQueryBuilder('cs')\n .select('cs.class_id', 'classId')\n .addSelect('COUNT(cs.id)', 'count')\n .where('cs.status = :status', { status: 'active' })\n .groupBy('cs.class_id')\n .getRawMany();"}
{"path":"apps/server/src/classes/classes.service.ts","start_line":283,"end_line":289,"category":"security","severity":"medium","content":"getStudents returns the raw ClassStudent entities together with the full `student` relation, exposing every student column (mobile, parent contact, etc.) to the caller. This is inconsistent with findOne, which deliberately maps only safe fields (studentName, studentNo). If the endpoint is consumed by teachers/agents, this leaks personal data that may not be authorized. Map the response to a sanitized shape (id, studentId, name, studentNo, joinDate, leaveDate, status) as done in findOne, or use explicit select columns.","suggestion_code":null,"existing_code":" async getStudents(classId: number) {\n return this.classStudentRepo.find({\n where: { classId },\n relations: ['student'],\n order: { createdAt: 'ASC' as const },\n });\n }"}
{"path":"apps/server/src/classes/classes.service.ts","start_line":180,"end_line":183,"category":"bug","severity":"medium","content":"create() performs multiple independent writes (class, student memberships, teacher assignments, batch import) without a transaction. If any later step fails (e.g. batchImportStudents throws on a duplicate dingUserId, or teacher save fails), the class is already persisted, leaving a partially-created class and orphaned records. Wrap the whole sequence in `this.dataSource.transaction(...)` (the DataSource is already injected) so all-or-nothing semantics are guaranteed.","suggestion_code":null,"existing_code":" const saved = await this.classRepo.save(cls);\n\n // add students\n if (studentIds?.length) {"}
{"path":"apps/server/src/classes/classes.service.ts","start_line":361,"end_line":366,"category":"bug","severity":"medium","content":"Check-then-insert race: two concurrent addTeacher calls with the same userId+roleType can both pass the findOne existence check and both insert, creating duplicate (classId, userId, roleType) rows. The same pattern exists in addStudents for (classId, studentId). Protect with a database unique constraint and catch the duplicate-key error (or perform the existence check and insert inside a transaction/serializable isolation).","suggestion_code":null,"existing_code":" const existing = await this.classTeacherRepo.findOne({\n where: { classId, userId: dto.userId, roleType: dto.roleType },\n });\n if (existing) throw new BadRequestException('该教师已分配此角色');\n\n const entry = this.classTeacherRepo.create({"}
{"path":"apps/server/src/classes/classes.service.ts","start_line":105,"end_line":105,"category":"security","severity":"low","content":"The keyword is interpolated into a LIKE pattern, so user-supplied `%` and `_` act as SQL wildcards: a keyword of `%` matches every class, and `_` matches any single character, leading to unintended search results. Since it is parameterized there is no SQL injection, but the wildcard characters should be escaped (e.g. `keyword.replace(/[%_]/g, (m) => '\\\\' + m)`) or matched with a case-insensitive function that treats them literally.","suggestion_code":null,"existing_code":" if (query.keyword) where.name = Like(`%${query.keyword}%`);"}
{"path":"apps/server/src/classes/classes.controller.ts","start_line":147,"end_line":149,"category":"security","severity":"high","content":"All mutation endpoints in this controller (update, archive, restore, remove, purge, batchImportStudents, addStudents, removeStudent, addTeacher, removeTeacher, removeTeacherAssignment) rely only on the global @RequirePermission('class:edit') and never call `await this.assertReadAccess(req, +id)`. The corresponding service methods (ClassesService.update/archive/purge/addStudents/addTeacher...) only perform existence checks, not membership checks. Since the read endpoints (findOne, getSchedule, getStudents, ...) do enforce assertReadAccess, a user who holds `class:edit` but is only assigned to some classes (e.g., a subject teacher) can modify, archive, purge, or import/remove students in ANY class — a broken-access-control / IDOR vulnerability. Add `await this.assertReadAccess(req, +id)` to every mutation endpoint, consistent with the read path.","suggestion_code":null,"existing_code":" @Put(':id')\n @RequirePermission('class:edit')\n async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: AuthenticatedRequest) {"}
{"path":"apps/server/src/classes/classes.controller.ts","start_line":167,"end_line":169,"category":"security","severity":"high","content":"Destructive endpoint: purge permanently deletes a class, but unlike the read endpoints it never calls `assertReadAccess(req, +id)`, so any caller with the `class:purge`/`class:edit` permission can permanently delete a class they were never assigned to. Add `await this.assertReadAccess(req, +id)` before calling the service.","suggestion_code":null,"existing_code":" @Delete(':id/permanent')\n @RequirePermission('class:purge')\n async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {"}
{"path":"apps/server/src/classes/classes.controller.ts","start_line":127,"end_line":129,"category":"security","severity":"high","content":"Bulk import endpoint imports students (by DingTalk userId) into a class without any class-level access check — same missing `assertReadAccess(req, +id)` as the other mutation endpoints. A user with only `class:edit` can inject students into any class they are not assigned to.","suggestion_code":null,"existing_code":" @Post(':id/students/import')\n @RequirePermission('class:edit')\n async batchImportStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: BatchImportStudentsDto) {"}
{"path":"apps/server/src/classes/classes.controller.ts","start_line":234,"end_line":244,"category":"bug","severity":"medium","content":"`void this.notificationsService.create(...)` discards the promise without awaiting: if creation fails, the rejection becomes an unhandled promise rejection (Node's default behavior can crash the process), and the surrounding try/catch can never catch it because the promise is not awaited. The comment '通知失败不影响班级新增结果' is therefore not achieved for notification failures (the catch only protects the awaited `findOne`). Await the call inside the try/catch or attach `.catch(() => {})`.","suggestion_code":null,"existing_code":" try {\n const cls = await this.service.findOne(+id);\n if (cls.headTeacherId) {\n void this.notificationsService.create({\n recipientIds: [cls.headTeacherId],\n type: NotificationType.CLASS_CHANGE,\n title: '学员变动',\n content: `班级新增${result.added}名学生`,\n });\n }\n } catch {"}
{"path":"apps/server/src/classes/classes.controller.ts","start_line":278,"end_line":285,"category":"bug","severity":"medium","content":"Same fire-and-forget issue as addStudents: `void this.notificationsService.create(...)` is not awaited, so a rejected promise is an unhandled rejection and the try/catch never catches it — the intended '通知失败不影响班级分配结果' fallback does not work. Use `await` inside the try/catch or `.catch(() => {})`.","suggestion_code":null,"existing_code":" try {\n void this.notificationsService.create({\n recipientIds: [dto.userId],\n type: NotificationType.CLASS_CHANGE,\n title: '班级分配',\n content: `您已被分配到班级担任${teacherRoleLabels[dto.roleType] ?? dto.roleType}角色`,\n });\n } catch {"}
{"path":"apps/server/src/classes/classes.controller.ts","start_line":216,"end_line":217,"category":"bug","severity":"medium","content":"If `workbook.xlsx.write(res)` throws mid-stream, `res.end()` is never called and the HTTP response hangs without error handling; wrap the write/end in try/finally or return a NestJS StreamableFile. Also, `attachment; filename=${encodeURIComponent('班级花名册-...')}.xlsx` is not RFC 5987 compliant for non-ASCII names — browsers may fail to decode the Chinese filename; use `filename*=UTF-8''<encoded>` (and a plain ASCII `filename=` fallback).","suggestion_code":null,"existing_code":" await workbook.xlsx.write(res);\n res.end();"}
{"path":"apps/server/src/classes/classes.controller.ts","start_line":134,"end_line":136,"category":"maintainability","severity":"low","content":"The dedicated archive endpoint performs no audit logging, while the legacy `remove` endpoint (which internally calls the same `service.archive`) logs action '归档班级'. Archiving via this new endpoint is therefore not covered by the audit trail — add a matching `logAudit` call here.","suggestion_code":null,"existing_code":" @Put(':id/archive')\n @RequirePermission('class:edit')\n async archive(@Param('id', ParseIntPipe) id: number) {"}
{"path":"apps/server/src/classes/classes-queries.service.ts","start_line":153,"end_line":155,"category":"performance","severity":"medium","content":"Performance: this method loads every matching attendance record into memory (`getMany()`) and then filters/counts in JS. On a class with many students/days this fetches all row data (including unused columns) just to compute five counters. Prefer SQL-side aggregation, e.g. `qb.select('ar.status', 'status').addSelect('COUNT(*)', 'count').groupBy('ar.status').getRawMany()` (or conditional `SUM(CASE ...)`), so the DB returns only a few aggregated rows.","suggestion_code":null,"existing_code":" const rows = await qb.getMany();\n\n const total = rows.length;"}
{"path":"apps/server/src/classes/classes-queries.service.ts","start_line":156,"end_line":159,"category":"maintainability","severity":"medium","content":"Hardcoded business status strings ('present'/'late'/'absent'/'leave', and 'active' in agentSearchClasses) are duplicated inline. More importantly, any record whose status is not one of these four is still included in `total`, so it silently inflates the denominator and skews presentRate/absentRate/etc. without being represented in any bucket. Centralize the status values in constants/enums and either count the unknown remainder explicitly or exclude it from the rate denominator.","suggestion_code":null,"existing_code":" const present = rows.filter((r) => r.status === 'present').length;\n const late = rows.filter((r) => r.status === 'late').length;\n const absent = rows.filter((r) => r.status === 'absent').length;\n const leave = rows.filter((r) => r.status === 'leave').length;"}
{"path":"apps/server/src/classes/classes-queries.service.ts","start_line":56,"end_line":56,"category":"bug","severity":"low","content":"The keyword is interpolated into `LIKE '%...%'` without escaping. A user-supplied `%` or `_` acts as a wildcard (e.g. searching `%` returns every non-archived class), and the leading wildcard also defeats any index on name/code. Escape `\\`, `%`, `_` in the keyword (and use `ESCAPE '\\\\'`) before building the pattern.","suggestion_code":null,"existing_code":" if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` });"}
{"path":"apps/server/src/classrooms/classroom-template.ts","start_line":1,"end_line":1,"category":"maintainability","severity":"low","content":"This constant is declared without an explicit type, so `key` is inferred as plain `string`. Consumers of CLASSROOM_TEMPLATE_COLUMNS then lose compile-time checking — a typo in a key (e.g. `roomtType`) would silently pass and only fail at runtime when the row data is read. Consider defining an explicit column type (e.g. `interface ClassroomTemplateColumn { header: string; key: 'name' | 'building' | 'floor' | 'roomType' | 'capacity'; width: number }`) or appending `as const` so key names are type-checked against the data model.","suggestion_code":null,"existing_code":"export const CLASSROOM_TEMPLATE_COLUMNS = ["}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.module.ts","start_line":24,"end_line":24,"category":"maintainability","severity":"low","content":"OperationLogsModule is decorated with @Global() (see operation-logs.module.ts), so its providers are already available to every module without an explicit import. This import is redundant; removing it reduces coupling and keeps the module wiring declarative. (Not a functional defect.)","suggestion_code":null,"existing_code":" OperationLogsModule,"}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.controller.ts","start_line":205,"end_line":206,"category":"bug","severity":"high","content":"`fs.createReadStream(fullPath)` is piped to the response without any error handler. If the contract file is missing (e.g., deleted between `getContractPath` and streaming) or a read error occurs, the ReadStream emits an 'error' event with no listener, which throws an unhandled 'error' event and can crash the whole Node process (or leave the client hanging). Also, `Content-Length` is not set. Attach an error listener and destroy the response appropriately, e.g.:\n\n```ts\nconst stream = fs.createReadStream(fullPath);\nstream.on('error', (err) => {\n if (!res.headersSent) res.status(500).json({ message: '合同文件读取失败' });\n else res.destroy(err);\n});\nstream.pipe(res);\n```","suggestion_code":null,"existing_code":" const stream = fs.createReadStream(fullPath);\n stream.pipe(res);"}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.controller.ts","start_line":63,"end_line":64,"category":"bug","severity":"medium","content":"Non-numeric `month`/`year` query values (e.g. `month=abc`) are converted with `+month` to NaN, and since `NaN < 1` and `NaN > 12` are both false, the range check is bypassed and NaN is passed to the service. The `year` parameter is not validated at all. Use `Number.isInteger` validation consistent with `getUnavailableDates`:\n\n```ts\nconst y = year ? +year : now.getFullYear();\nconst m = month ? +month : now.getMonth() + 1;\nif (!Number.isInteger(m) || m < 1 || m > 12) throw new BadRequestException('月份必须在 1-12 之间');\nif (!Number.isInteger(y) || y < 2000 || y > 2100) throw new BadRequestException('年份不合法');\n```","suggestion_code":null,"existing_code":" const m = month ? +month : now.getMonth() + 1;\n if (m < 1 || m > 12) throw new BadRequestException('月份必须在 1-12 之间');"}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.controller.ts","start_line":51,"end_line":52,"category":"bug","severity":"low","content":"`+classroomId` on a non-numeric string (e.g. `classroomId=abc`) yields NaN, which is forwarded to the service without validation and can produce unexpected query behavior (NaN in DB queries). Validate with `Number.isInteger` (as done in `getUnavailableDates`) before converting, or return a 400 for invalid values.","suggestion_code":null,"existing_code":" classroomId: classroomId ? +classroomId : undefined,\n lesseeOrganizationId: lesseeOrganizationId ? +lesseeOrganizationId : undefined,"}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.controller.ts","start_line":201,"end_line":204,"category":"bug","severity":"low","content":"Putting `encodeURIComponent(originalName)` inside the plain `filename` parameter is not reliably decoded by browsers for non-ASCII names (many will download the literal `%E4%B8%AD...` string). Use the RFC 5987 form (`filename*=UTF-8''...`) for the encoded name and keep a plain ASCII fallback in `filename`, so Chinese filenames download correctly.","suggestion_code":null,"existing_code":" res.setHeader(\n 'Content-Disposition',\n `attachment; filename=\"${encodeURIComponent(originalName)}\"`,\n );"}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.controller.ts","start_line":176,"end_line":178,"category":"security","severity":"low","content":"The file filter only trusts the client-supplied `file.mimetype` header, which can be trivially forged — a non-PDF file renamed/sent with `Content-Type: application/pdf` would be accepted and stored as a contract. Consider validating the actual file content (PDF magic bytes `%PDF`) and/or the extension, and serving downloads with a hardcoded `application/pdf` content type rather than trusting the uploaded metadata.","suggestion_code":null,"existing_code":" if (file.mimetype !== 'application/pdf') {\n return cb(new BadRequestException('仅支持 PDF 文件'), false);\n }"}
{"path":"apps/server/src/classrooms/classrooms.controller.ts","start_line":209,"end_line":211,"category":"bug","severity":"high","content":"Static route is shadowed by the parameterized route: `@Get(':id')` is declared before `@Get('report')`, so a request to `GET /classrooms/report` is matched by `findOne('report')` first, where `+('report')` evaluates to `NaN` (likely causing a DB error / 500) and the report endpoint becomes unreachable. Move `@Get('report')` (and any other static route) above `@Get(':id')`, as already done for `@Get('template')`. Additionally, consider rejecting non-numeric `:id` values in `findOne` to harden the param route.","suggestion_code":null,"existing_code":" @Get('report')\n @RequirePermission('classroom:view')\n async exportReport("}
{"path":"apps/server/src/classrooms/classrooms.controller.ts","start_line":174,"end_line":177,"category":"security","severity":"medium","content":"`file` is dereferenced without a null check and the upload has no size/type limits. If the multipart field `file` is missing, `file.buffer` throws an unhandled 500; and without a `fileSize` limit, a crafted/large workbook can exhaust memory during `workbook.xlsx.load` (zip-bomb style DoS). Check `if (!file?.buffer) throw ...`, configure limits on `FileInterceptor` (e.g. `{ limits: { fileSize: 10 * 1024 * 1024 } }`), and wrap the parsing in try/catch to return a user-friendly message for corrupt files.","suggestion_code":" async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {\n if (!file?.buffer) {\n throw new BadRequestException('请上传文件');\n }\n const { ipAddress, userAgent } = extractRequestInfo(req);\n const workbook = new ExcelJS.Workbook();\n try {\n await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));","existing_code":" async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {\n const { ipAddress, userAgent } = extractRequestInfo(req);\n const workbook = new ExcelJS.Workbook();\n await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));"}
{"path":"apps/server/src/classrooms/classrooms.controller.ts","start_line":191,"end_line":191,"category":"bug","severity":"low","content":"`Number(...) || undefined` silently drops valid falsy values: a floor of 0 (e.g. basement classrooms) or a capacity of 0 becomes `undefined` during import, corrupting the imported data. Use an explicit empty-string check instead, e.g. `cellValueText(...) === '' ? undefined : Number(...)`.","suggestion_code":" floor: cellValueText(row.getCell(3).value) === '' ? undefined : Number(row.getCell(3).value),","existing_code":" floor: Number(row.getCell(3).value) || undefined,"}
{"path":"apps/server/src/classroom-rentals/dto/rental.dto.ts","start_line":16,"end_line":20,"category":"bug","severity":"high","content":"No cross-field validation guarantees that `endDate` is on/after `startDate`. A request with `endDate` earlier than `startDate` (or equal, if zero-length periods are invalid) passes validation and can create an invalid rental period. The same gap exists in `UpdateRentalDto`. Add a class-level custom validator (e.g., a `ValidatorConstraint` or a `@ValidateIf`/`@IsAfter` combination) that compares the two fields for both DTOs.","suggestion_code":null,"existing_code":" startDate: string;\n\n @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601({ strict: true })\n endDate: string;"}
{"path":"apps/server/src/classroom-rentals/dto/rental.dto.ts","start_line":37,"end_line":40,"category":"maintainability","severity":"medium","content":"`UpdateRentalDto` duplicates every field and validator from `CreateRentalDto`, which is error-prone when constraints change (e.g., adding `@IsISO8601` or `@Min` must be done in two places). Derive the update DTO from the create DTO using `PartialType(CreateRentalDto)` from `@nestjs/mapped-types` (or `@nestjs/swagger`) so there is a single source of truth.","suggestion_code":null,"existing_code":"export class UpdateRentalDto {\n @IsOptional()\n @IsInt()\n classroomId?: number;"}
{"path":"apps/server/src/classroom-rentals/dto/rental.dto.ts","start_line":22,"end_line":25,"category":"bug","severity":"medium","content":"`@IsOptional()` skips validation for both `undefined` and `null`. A client can therefore send `dailyRate: null` (or `totalAmount: null`) explicitly and pass validation, which can violate a NOT NULL DB constraint or inject nulls into business logic. If `null` should be rejected while the field remains optional, use `@ValidateIf(o => o.dailyRate !== undefined)` instead of `@IsOptional()`.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsNumber()\n @Min(0.01)\n dailyRate?: number;"}
{"path":"apps/server/src/classroom-rentals/dto/rental.dto.ts","start_line":27,"end_line":30,"category":"bug","severity":"low","content":"Money fields accept any number of decimal places (e.g., `0.01999`) since `@IsNumber()` has no precision bound. This can cause rounding/precision issues when persisted to a DECIMAL/NUMERIC column or in amount computations. Constrain precision, e.g., `@IsNumber({ maxDecimalPlaces: 2 })`.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsNumber()\n @Min(0.01)\n totalAmount?: number;"}
{"path":"apps/server/src/classroom-rentals/dto/rental.dto.ts","start_line":32,"end_line":34,"category":"security","severity":"low","content":"`notes` is an unbounded free-text field with no maximum length, allowing arbitrarily large payloads to be accepted and persisted. Add `@MaxLength(...)` (e.g., 1000) alongside `@IsString()` to bound input size.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsString()\n notes?: string;"}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.service.ts","start_line":163,"end_line":164,"category":"bug","severity":"medium","content":"日期字段用 `String()` 序列化存在格式问题TypeORM 的 `getRawMany` 对 date 列(如 mysql2 驱动)默认返回 JS `Date` 对象,此时 `String(date)` 会得到类似 \"Sat Aug 09 2026 00:00:00 GMT+0800\" 的完整文本,而非 API 约定/调用方期望的 'YYYY-MM-DD';即便驱动返回字符串也可能附带时间部分(如 '2026-08-09 00:00:00')。建议用 dayjs 显式格式化后再返回。","suggestion_code":" startDate: dayjs(row.startDate).format('YYYY-MM-DD'),\n endDate: dayjs(row.endDate).format('YYYY-MM-DD'),","existing_code":" startDate: String(row.startDate),\n endDate: String(row.endDate),"}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.service.ts","start_line":214,"end_line":217,"category":"bug","severity":"medium","content":"冲突检查与保存之间存在 TOCTOU 竞态:`findConflicts` 先查询,随后才 `repo.save`,两个并发请求(同一教室/时间段)都可能通过冲突检查并各自保存成功,导致重叠租赁。建议将冲突检查与插入放入同一事务并使用行锁(如 `SELECT ... FOR UPDATE`),或借助数据库唯一约束/排他区间约束来保证并发安全。","suggestion_code":null,"existing_code":" const conflicts = await this.findConflicts(dto.classroomId, dto.startDate, dto.endDate);\n if (conflicts.length > 0) {\n throw rentalConflictError('该教室在此时间段已有租赁', conflicts);\n }"}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.service.ts","start_line":352,"end_line":356,"category":"security","severity":"medium","content":"合同上传仅校验客户端可伪造的 `file.mimetype` 和扩展名,未校验文件实际内容,且没有文件大小限制(恶意/误传的大文件或伪装成 PDF 的内容会被落盘)。建议增加 PDF 魔数校验(文件头 `%PDF-`)并在路由/此处限制文件大小。","suggestion_code":" const MAX_CONTRACT_SIZE = 10 * 1024 * 1024;\n if (file.size > MAX_CONTRACT_SIZE) {\n throw new BadRequestException('合同文件不能超过 10MB');\n }\n if (!file.buffer.subarray(0, 5).equals(Buffer.from('%PDF-'))) {\n throw new BadRequestException('文件内容不是有效的 PDF');\n }","existing_code":" if (file.mimetype !== 'application/pdf') {\n throw new BadRequestException('仅支持 PDF 文件');\n }\n const ext = path.extname(file.originalname).toLowerCase();\n if (ext !== '.pdf') throw new BadRequestException('文件扩展名必须为 .pdf');"}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.service.ts","start_line":403,"end_line":404,"category":"security","severity":"low","content":"`fullPath.startsWith(this.uploadDir)` 是前缀匹配,可被绕过:例如 uploadDir 为 `/uploads/contracts` 时,`/uploads/contracts-evil/..` 这类路径同样以该前缀开头。虽然当前 `contractPath` 由服务端生成uuid.pdf但更稳妥的做法是用 `path.relative` 判断是否越界,并保持与 `attachContract` 中同样严格。","suggestion_code":" if (path.relative(this.uploadDir, fullPath).startsWith('..')) throw new BadRequestException('路径非法');","existing_code":" const fullPath = path.join(this.uploadDir, rental.contractPath);\n if (!fullPath.startsWith(this.uploadDir)) throw new BadRequestException('路径非法');"}
{"path":"apps/server/src/classroom-rentals/classroom-rentals.service.ts","start_line":282,"end_line":282,"category":"maintainability","severity":"low","content":"业务状态大量使用裸字符串魔法值:`status: 'active'`(机构查询)、`scheduleType: 'RENTAL'`、`status: 'inactive'`(排课更新)。这些值散落在 `cancel`/`remove`/`create`/`update` 中,一旦实体枚举或字段值调整容易漏改导致静默失效。建议提取为常量/枚举并复用。","suggestion_code":" await this.scheduleRepo.update(\n { rentalId: id, scheduleType: ClassScheduleType.RENTAL },\n { status: ClassScheduleStatus.INACTIVE },\n );","existing_code":" await this.scheduleRepo.update({ rentalId: id, scheduleType: 'RENTAL' }, { status: 'inactive' });"}
{"path":"apps/server/src/classrooms/dto/classroom.dto.ts","start_line":55,"end_line":57,"category":"bug","severity":"medium","content":"`@IsEnum` expects an enum object, not an array of enum values. Passing an array makes class-validator treat the array as an enum whose keys are numeric indices (`0`, `1`), so the generated validation error message would be misleading (e.g. \"must be one of the following values: 0, 1\") and the check only works by accident for string-valued arrays. Use the dedicated `@IsIn` validator to restrict to the allowed statuses (or `@IsEnum(ClassroomStatus)` if all statuses are allowed).","suggestion_code":" @IsOptional()\n @IsIn([ClassroomStatus.AVAILABLE, ClassroomStatus.MAINTENANCE])\n status?: ClassroomStatus;","existing_code":" @IsOptional()\n @IsEnum([ClassroomStatus.AVAILABLE, ClassroomStatus.MAINTENANCE])\n status?: ClassroomStatus;"}
{"path":"apps/server/src/classrooms/dto/classroom.dto.ts","start_line":30,"end_line":33,"category":"maintainability","severity":"low","content":"`UpdateClassroomDto` duplicates all field declarations from `CreateClassroomDto` (only differing in `status` and the `name` validation). Consider deriving it with `PartialType(CreateClassroomDto)` from `@nestjs/mapped-types` and only adding the `status` field, which avoids the two DTOs drifting apart when fields are added.","suggestion_code":null,"existing_code":"export class UpdateClassroomDto {\n @IsOptional()\n @IsString()\n name?: string;"}
{"path":"apps/server/src/common/dayjs.ts","start_line":4,"end_line":5,"category":"maintainability","severity":"medium","content":"The comment claims the utc plugin adds fixed-timezone support for \"Asia/Shanghai\", but the dayjs `utc` plugin only provides `.utc()`, `.local()` and `.utcOffset()` methods — it does NOT support named IANA timezones (that requires the separate `timezone` plugin, otherwise `dayjs().tz('Asia/Shanghai')` throws \"dayjs.tz is not a function\"). Existing callers work only because they hardcode `.utcOffset(8)` individually. Either extend the timezone plugin and configure the zone once, or fix this comment so it doesn't mislead future developers into relying on named-timezone support.","suggestion_code":"// 服务端统一 dayjs 实例:扩展 utc 插件以支持固定偏移UTC+8等价于 Asia/Shanghai无夏令时。\ndayjs.extend(utc);","existing_code":"// 服务端统一 dayjs 实例:扩展 utc 插件以支持固定时区Asia/Shanghai 无夏令时,恒为 UTC+8。\ndayjs.extend(utc);"}
{"path":"apps/server/src/common/buffer.ts","start_line":11,"end_line":14,"category":"bug","severity":"medium","content":"`buffer.buffer` is typed `ArrayBufferLike` in Node typings and may be a `SharedArrayBuffer` (e.g., buffers created over shared memory in worker threads). `SharedArrayBuffer.prototype.slice()` returns a `SharedArrayBuffer`, not an `ArrayBuffer`, so the `as ArrayBuffer` cast is unsound: at runtime the returned value would not satisfy `ArrayBuffer` semantics, breaking consumers such as `xlsx.load`. Prefer copying into a fresh `ArrayBuffer` (e.g., `Uint8Array.from(buffer).buffer`) or explicitly guarding `buffer.buffer instanceof ArrayBuffer` before the cast.","suggestion_code":null,"existing_code":" return buffer.buffer.slice(\n buffer.byteOffset,\n buffer.byteOffset + buffer.byteLength,\n ) as ArrayBuffer;"}
{"path":"apps/server/src/common/buffer.ts","start_line":11,"end_line":14,"category":"performance","severity":"low","content":"Minor performance note: when the Buffer is not backed by the internal pool (e.g., `Buffer.from(arrayBuffer)` or `Buffer.allocUnsafeSlow`), `byteOffset === 0` and `byteLength === buffer.buffer.byteLength`, so `.slice()` makes a full copy of data that could be returned directly. A fast path such as `if (buffer.byteOffset === 0 && buffer.byteLength === buffer.buffer.byteLength) return buffer.buffer as ArrayBuffer;` avoids the unnecessary copy for the common standalone-buffer case.","suggestion_code":null,"existing_code":" return buffer.buffer.slice(\n buffer.byteOffset,\n buffer.byteOffset + buffer.byteLength,\n ) as ArrayBuffer;"}
{"path":"apps/server/src/classes/dto/class.dto.ts","start_line":174,"end_line":176,"category":"maintainability","severity":"medium","content":"`AddTeacherDto` is a line-for-line duplicate of `ClassTeacherItemDto` (same fields and validators). When the teacher contract changes, both classes must be updated in sync or they will drift. Reuse the existing DTO instead, e.g. `export class AddTeacherDto extends ClassTeacherItemDto {}` or import `ClassTeacherItemDto` directly.","suggestion_code":null,"existing_code":"export class AddTeacherDto {\n @IsInt()\n userId: number;"}
{"path":"apps/server/src/classes/dto/class.dto.ts","start_line":186,"end_line":189,"category":"maintainability","severity":"medium","content":"`QueryClassScheduleDto` is byte-for-byte identical to `QueryClassAttendanceSummaryDto`. Duplicated DTOs will silently diverge when validation rules change. Consider reusing one of them or extracting a shared base class (e.g. `QueryDateRangeDto`).","suggestion_code":null,"existing_code":"export class QueryClassScheduleDto {\n @IsOptional()\n @IsDateString()\n startDate?: string;"}
{"path":"apps/server/src/classes/dto/class.dto.ts","start_line":21,"end_line":22,"category":"maintainability","severity":"medium","content":"Fields validated with `@IsEnum` (`roleType` here, and `classType`/`status` in `CreateClassDto`/`UpdateClassDto`) are declared as `string`, which defeats compile-time type safety — callers can pass arbitrary strings without TS catching it. Declare them with the enum type, e.g. `roleType: TeacherRoleType`, `classType: ClassType`, `status?: ClassStatus`.","suggestion_code":" @IsEnum(TeacherRoleType)\n roleType: TeacherRoleType;","existing_code":" @IsEnum(TeacherRoleType)\n roleType: string;"}
{"path":"apps/server/src/classes/dto/class.dto.ts","start_line":168,"end_line":171,"category":"bug","severity":"low","content":"`AddStudentsDto` only has `@IsArray`, so an empty `studentIds` array passes validation. For an add-students operation this is almost certainly a no-op/invalid request; add `@ArrayNotEmpty()` (as already done in `BatchImportStudentsDto`) unless an empty list is intentionally a no-op.","suggestion_code":"export class AddStudentsDto {\n @IsArray()\n @ArrayNotEmpty()\n @IsInt({ each: true })\n studentIds: number[];","existing_code":"export class AddStudentsDto {\n @IsArray()\n @IsInt({ each: true })\n studentIds: number[];"}
{"path":"apps/server/src/common/stringify.ts","start_line":10,"end_line":12,"category":"bug","severity":"low","content":"`String(value)` 对 unknown 并非完全“安全”:当 value 是 `Object.create(null)` 或自定义 `toString`/`Symbol.toPrimitive` 抛异常/返回非原始值的对象时,`String()` 会抛出 `TypeError`,而不是返回字符串。这与注释中“安全字符串化 unknown”的承诺不符也可能让调用方误以为此处绝无异常。若此助手仅用于日志/消息展示可接受,但建议在注释中注明该边界,或对对象类型显式兜底(如 try/catch 或自定义 toString 调用),保证函数名所承诺的安全性。","suggestion_code":null,"existing_code":"export function stringify(value: unknown): string {\n return String(value);\n}"}
{"path":"apps/server/src/common/request-utils.ts","start_line":11,"end_line":15,"category":"security","severity":"high","content":"Client IP is derived by blindly trusting the `x-forwarded-for`/`x-real-ip` headers. These headers are client-controlled unless the server is only reachable behind a trusted reverse proxy that overwrites them. If the service can be hit directly, an attacker can spoof any IP with `X-Forwarded-For: <victim-ip>`, bypassing IP-based rate limiting / access control and poisoning audit logs. Suggest honoring these headers only when the direct peer (`req.connection.remoteAddress`) is a known trusted proxy (or relying on the framework's `trust proxy` setting), and falling back to the connection address otherwise.","suggestion_code":null,"existing_code":" const forwarded =\n req.headers?.['x-forwarded-for'] ||\n req.headers?.['x-real-ip'] ||\n req.connection?.remoteAddress ||\n '';"}
{"path":"apps/server/src/common/request-utils.ts","start_line":16,"end_line":16,"category":"bug","severity":"medium","content":"`split(',')[0]` takes the leftmost value of `X-Forwarded-For`. In a standard proxy chain each proxy appends the client address to the right (e.g. `XFF: <client-supplied>, <real-client>`), so the leftmost value is the original client-supplied (spoofable) one, while the real client IP is the rightmost / last untrusted hop. This compounds the spoofing issue above: even behind a trusted proxy that only appends, the extracted IP will be wrong/spoofable. Consider parsing from the right based on the number of trusted proxies, or having the proxy overwrite the header / use `x-real-ip` as the authoritative source.","suggestion_code":null,"existing_code":" const ipAddress = String(forwarded).split(',')[0].trim() || 'unknown';"}
{"path":"apps/server/src/classroom-rentals/rental-schedule.service.ts","start_line":196,"end_line":197,"category":"bug","severity":"medium","content":"Timezone bug: `new Date('YYYY-MM-DD')` parses as UTC midnight, but `d.getDate()` / `d.setDate()` below run in the server's local timezone. On any server west of UTC (or in a DST-observing timezone) matrix entries get placed on the wrong calendar day (e.g., UTC-5: Aug 9 00:00 UTC = Aug 8 19:00 local → `getDate()` returns 8), and the day-by-day iteration can skip/duplicate days. The rest of this file deliberately uses UTC-based helpers (`toUtcDate` + `dayjs(...).utcOffset(8)`), so this path is inconsistent. Parse/iterate with UTC helpers instead, e.g. build `effStart/effEnd` via `toUtcDate` and use `getUTCDate()`/`setUTCDate()` (or `dayjs(...).utcOffset(8)`).","suggestion_code":null,"existing_code":" const start = new Date(rental.startDate);\n const end = new Date(rental.endDate);"}
{"path":"apps/server/src/classroom-rentals/rental-schedule.service.ts","start_line":246,"end_line":246,"category":"bug","severity":"medium","content":"Same timezone issue as the rental loop: `d.getDay()` here is the local weekday, so on servers west of UTC the weekday check `dow !== sched.weekDay` and the day key `d.getDate()` can be off by one day, producing wrong schedule overlay days. Use UTC-based day/weekday helpers consistently.","suggestion_code":null,"existing_code":" const dow = d.getDay() === 0 ? 7 : d.getDay();"}
{"path":"apps/server/src/classroom-rentals/rental-schedule.service.ts","start_line":318,"end_line":318,"category":"bug","severity":"medium","content":"Status mapping is wrong for ended rentals: any status other than CANCELLED (including ENDED) is written to `class_schedules` as `'active'`. A rental that has ended will leave an active RENTAL schedule in the DB, which can block/reuse the period incorrectly in other features reading `class_schedules.status = 'active'` (e.g. attendance or teacher-schedule views). Map ENDED → `'ended'` (or remove/ignore the schedule) instead of defaulting everything to `'active'`, and prefer enum constants over string literals.","suggestion_code":null,"existing_code":" status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active',"}
{"path":"apps/server/src/classroom-rentals/rental-schedule.service.ts","start_line":88,"end_line":89,"category":"maintainability","severity":"low","content":"Hardcoded string literals `'active'` and `'INTERNAL'` are repeated across this file (`findConflicts`, `getSchedule`, `syncScheduleFromRental`). `ClassroomRentalStatus.ACTIVE` and the exported `ScheduleType.INTERNAL` enum already exist and should be reused so the values cannot drift (a typo here silently disables conflict detection).","suggestion_code":null,"existing_code":" .andWhere('cs.status = :status', { status: 'active' })\n .andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })"}
{"path":"apps/server/src/classroom-rentals/rental-schedule.service.ts","start_line":183,"end_line":184,"category":"maintainability","severity":"low","content":"`any` is used for the map/matrix value types. Since the cell shape is known (scheduleType/rentalId/organizationName/color, or scheduleId/subject/teacherName/startTime/endTime), define a small discriminated interface (e.g. `RentalCell | InternalCell`) so the frontend contract is type-checked instead of unchecked `any`.","suggestion_code":null,"existing_code":" const organizationMap = new Map<number, any>();\n const matrix: Record<number, Record<number, any>> = {};"}
{"path":"apps/server/src/classroom-rentals/rental-schedule.service.ts","start_line":30,"end_line":31,"category":"maintainability","severity":"low","content":"`dayjs('2026-8-01')` / `dayjs('2026-8')` are non-ISO strings: dayjs falls back to engine-dependent `new Date()` parsing for them (works in V8, fails or mis-parses in other engines), and the resulting `monthStart` string is only lexicographically comparable with DB dates because of the `.format()` zero-padding. Zero-pad the month explicitly to be safe: `dayjs(`${year}-${String(month).padStart(2, '0')}-01`)` and same for `monthEnd`.","suggestion_code":null,"existing_code":" const monthStart = dayjs(`${year}-${month}-01`).format('YYYY-MM-DD');\n const monthEnd = dayjs(`${year}-${month}`).endOf('month').format('YYYY-MM-DD');"}
{"path":"apps/server/src/classroom-rentals/rental-schedule.service.ts","start_line":330,"end_line":332,"category":"bug","severity":"medium","content":"`dateToWeekDay` uses `new Date(date)` + local `getDay()`, which is inconsistent with the UTC-based `toUtcDate` helper used everywhere else in this file and will return the previous day's weekday on servers with a negative UTC offset (shifting the synced schedule to the wrong weekday). Use `this.toUtcDate(date).getUTCDay()` (mapping 0→7).","suggestion_code":null,"existing_code":" dateToWeekDay(date: string): number {\n const d = new Date(date);\n const day = d.getDay();"}
{"path":"apps/server/src/classroom-rentals/rental-schedule.service.ts","start_line":303,"end_line":305,"category":"maintainability","severity":"low","content":"Non-atomic check-then-create: `findOne` followed by `create`/`save` has a race window — two concurrent calls for the same rental can both find no schedule and insert duplicate RENTAL rows. Add a unique index on `(rental_id, schedule_type)` (where rental_id is not null) and/or catch a unique-violation error and update instead of insert.","suggestion_code":null,"existing_code":" let schedule = await this.scheduleRepo.findOne({\n where: { rentalId: rental.id, scheduleType: 'RENTAL' },\n });"}
{"path":"apps/server/src/dashboard/dashboard.controller.ts","start_line":46,"end_line":49,"category":"security","severity":"high","content":"Authorization/data-scoping inconsistency: unlike `getStats`/`getClassAttendanceRanking`, these endpoints never resolve the requesting user's accessible class IDs. `getGanttData`, `getExpenseStats` and `getRoomExpenseRanking` return global (all rooms/expenses) data to any authenticated user holding the `dashboard:view` permission, even non-admin teachers — a horizontal privilege escalation / sensitive data exposure (esp. expense figures). Either pass `req` through `getAccessibleClassIds(req)` (and have the service scope the queries) or restrict these routes to admins/super-admins explicitly.","suggestion_code":null,"existing_code":" @Get('gantt')\n getGanttData(@Query() query: DashboardGanttQueryDto) {\n return this.service.getGanttData(query);\n }"}
{"path":"apps/server/src/dashboard/dashboard.controller.ts","start_line":66,"end_line":69,"category":"security","severity":"high","content":"Same authorization gap as the `gantt`/`expense-stats`/`room-ranking` endpoints: `getClassroomOccupancy` and `getClassroomUtilization` ignore the requesting user and expose global classroom schedule/rental data to any user with `dashboard:view`, including non-admin teachers. These should be scoped by the caller's accessible class IDs or explicitly restricted to admin-only roles.","suggestion_code":null,"existing_code":" @Get('classroom-occupancy')\n getClassroomOccupancy() {\n return this.service.getClassroomOccupancy();\n }"}
{"path":"apps/server/src/common/with-audit-log.ts","start_line":35,"end_line":36,"category":"bug","severity":"high","content":"If `logService.log()` throws (e.g. DB failure) after `operation()` has already succeeded, the exception propagates and the caller sees a failure even though the business operation was executed — it may retry the operation and produce duplicate side effects. Audit logging should not be able to fail the business operation; wrap the log call in try/catch and log the audit error instead of re-throwing.","suggestion_code":"const result = await operation();\n try {\n await logService.log({\n userId: req.user?.id,\n username: req.user?.username,\n ipAddress,\n userAgent,\n ...buildEntry(result),\n });\n } catch (err) {\n // 审计写入失败不应影响业务结果,仅记录错误\n console.error('Failed to write audit log', err);\n }\n return result;","existing_code":"const result = await operation();\n await logService.log({"}
{"path":"apps/server/src/common/with-audit-log.ts","start_line":32,"end_line":35,"category":"bug","severity":"medium","content":"When `operation()` throws, no audit record (including a failure entry) is written at all. For an audit-trail utility, failed attempts are typically the most important events to record. Consider catching the operation error, writing an entry with `status: 'failed'` (the `status` field already exists in `AuditLogEntry`), and then re-throwing the original error.","suggestion_code":"operation: () => Promise<T>,\n): Promise<T> {\n const { ipAddress, userAgent } = extractRequestInfo(req);\n try {\n const result = await operation();\n await logService.log({ userId: req.user?.id, username: req.user?.username, ipAddress, userAgent, ...buildEntry(result) });\n return result;\n } catch (err) {\n await logService\n .log({ userId: req.user?.id, username: req.user?.username, ipAddress, userAgent, ...buildEntry(err as T), status: 'failed' })\n .catch(() => {});\n throw err;\n }","existing_code":"operation: () => Promise<T>,\n): Promise<T> {\n const { ipAddress, userAgent } = extractRequestInfo(req);\n const result = await operation();"}
{"path":"apps/server/src/common/with-audit-log.ts","start_line":50,"end_line":56,"category":"maintainability","severity":"low","content":"`withAuditLog` and `logAudit` duplicate the same extract-and-log logic (both call `extractRequestInfo` and build the same `logService.log` payload). Extract a small private helper (e.g. `writeAuditLog(logService, req, entry)`) and reuse it in both functions to avoid divergence.","suggestion_code":null,"existing_code":"export async function logAudit(\n logService: OperationLogsService,\n req: AuditRequest,\n entry: AuditLogEntry,\n): Promise<void> {\n const { ipAddress, userAgent } = extractRequestInfo(req);\n await logService.log({"}
{"path":"apps/server/src/classrooms/classrooms.service.ts","start_line":399,"end_line":406,"category":"bug","severity":"high","content":"Usage report overcounts schedule days: `ClassSchedule` is a weekly recurring rule (single `weekDay` 17 spanning `startDate``endDate`, as used in `getUsageForClassrooms`), but this loop counts *every* calendar day in the range. A Monday-only weekly class spanning a month is counted as ~29 days instead of ~4-5, inflating `scheduleDays`/`usedDays` and `occupancyRate`. Only count days whose `getDay()` matches `s.weekDay` (JS: 0=Sun..6=Sat, so `target = s.weekDay % 7`).","suggestion_code":" for (const s of schedules) {\n if (!scheduleDaysByRoom[s.classroomId]) scheduleDaysByRoom[s.classroomId] = new Set();\n const effStart = new Date(Math.max(new Date(s.startDate).getTime(), start.getTime()));\n const effEnd = new Date(Math.min(new Date(s.endDate).getTime(), end.getTime()));\n const targetJsDay = s.weekDay % 7; // weekDay: 1=Mon..7=Sun -> JS getDay(): 0=Sun..6=Sat\n for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {\n if (d.getDay() !== targetJsDay) continue;\n scheduleDaysByRoom[s.classroomId].add(dayjs(d).utcOffset(8).format('YYYY-MM-DD'));\n }\n }","existing_code":" for (const s of schedules) {\n if (!scheduleDaysByRoom[s.classroomId]) scheduleDaysByRoom[s.classroomId] = new Set();\n const effStart = new Date(Math.max(new Date(s.startDate).getTime(), start.getTime()));\n const effEnd = new Date(Math.min(new Date(s.endDate).getTime(), end.getTime()));\n for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {\n scheduleDaysByRoom[s.classroomId].add(dayjs(d).utcOffset(8).format('YYYY-MM-DD'));\n }\n }"}
{"path":"apps/server/src/classrooms/classrooms.service.ts","start_line":409,"end_line":411,"category":"bug","severity":"medium","content":"`usedDays` is the plain sum of `rentalDays + scheduleDays`, so a single day with both a rental and a schedule is double-counted. This can make `usedDays > totalDays`, producing negative `idleDays` and occupancy rates above 100%. Compute the union of the two day sets instead.","suggestion_code":" const rentalDays = rentalDaysByRoom[c.id]?.size || 0;\n const scheduleDays = scheduleDaysByRoom[c.id]?.size || 0;\n const usedDays = new Set([...rentalDaysByRoom[c.id] ?? [], ...scheduleDaysByRoom[c.id] ?? []]).size;","existing_code":" const rentalDays = rentalDaysByRoom[c.id]?.size || 0;\n const scheduleDays = scheduleDaysByRoom[c.id]?.size || 0;\n const usedDays = rentalDays + scheduleDays;"}
{"path":"apps/server/src/classrooms/classrooms.service.ts","start_line":116,"end_line":119,"category":"bug","severity":"medium","content":"`update()` has no duplicate-name guard, unlike `create()` which rejects existing names. If `dto.name` is changed to a name already used by another classroom, duplicate classroom names will be created (there is no unique DB constraint on `classrooms.name`). Add the same existence check before updating when `dto.name` differs from the current name.","suggestion_code":" if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) {\n await this.assertNoActiveAllocations(id);\n }\n if (dto.name && dto.name !== classroom.name) {\n const exists = await this.repo.findOne({ where: { name: dto.name } });\n if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);\n }\n await this.repo.update(id, dto);","existing_code":" if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) {\n await this.assertNoActiveAllocations(id);\n }\n await this.repo.update(id, dto);"}
{"path":"apps/server/src/classrooms/classrooms.service.ts","start_line":340,"end_line":353,"category":"performance","severity":"medium","content":"`batchImport` runs sequential `findOne` + `save` per row (N+1 DB round-trips), and any DB error (e.g. a constraint violation on one row) throws out of the loop, aborting the rest of the import with a partial insert and no rollback or error reporting. Wrap each row in try/catch to collect per-row errors, and consider batching with `Promise.all` (with per-row error isolation) for large imports.","suggestion_code":" for (const row of rows) {\n if (!row.name?.trim()) {\n skipped++;\n continue;\n }\n try {\n const exists = await this.repo.findOne({ where: { name: row.name.trim() } });\n if (exists) {\n errors.push(`教室 ${row.name} 已存在`);\n skipped++;\n continue;\n }\n await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));\n imported++;\n } catch (err) {\n errors.push(`教室 ${row.name} 导入失败`);\n skipped++;\n }\n }","existing_code":" for (const row of rows) {\n if (!row.name?.trim()) {\n skipped++;\n continue;\n }\n const exists = await this.repo.findOne({ where: { name: row.name.trim() } });\n if (exists) {\n errors.push(`教室 ${row.name} 已存在`);\n skipped++;\n continue;\n }\n await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));\n imported++;\n }"}
{"path":"apps/server/src/classrooms/classrooms.service.ts","start_line":272,"end_line":277,"category":"bug","severity":"medium","content":"`String(schedule.startDate)` is only safe when the driver returns a date string ('YYYY-MM-DD'). If the driver returns a `Date` object (the raw-row interface at the top of the file explicitly allows `string | Date`), `String(Date)` yields 'YYYY-MM-DDT00:00:00.000Z', which lexicographically compares *greater* than `todayStr` — so a schedule/rental starting today would never be flagged as in use. Normalize to a 10-char prefix (or `dayjs().format('YYYY-MM-DD')`) before comparing. Same issue applies to the rental `isCurrent` check.","suggestion_code":" const startDateStr = String(schedule.startDate).slice(0, 10);\n const endDateStr = String(schedule.endDate).slice(0, 10);\n const isCurrent =\n startDateStr <= todayStr &&\n endDateStr >= todayStr &&\n Number(schedule.weekDay) === weekDay &&\n String(schedule.startTime) <= currentTime &&\n String(schedule.endTime) >= currentTime;","existing_code":" const isCurrent =\n String(schedule.startDate) <= todayStr &&\n String(schedule.endDate) >= todayStr &&\n Number(schedule.weekDay) === weekDay &&\n String(schedule.startTime) <= currentTime &&\n String(schedule.endTime) >= currentTime;"}
{"path":"apps/server/src/classrooms/classrooms.service.ts","start_line":266,"end_line":266,"category":"maintainability","severity":"low","content":"Business status/type values are hardcoded as string literals ('active', 'INTERNAL', 'archived', 'available', 'maintenance') scattered across queries and status assignments. The entities already export enums (`ScheduleType`, `ClassroomStatus`, `ClassroomRentalStatus`); using them (e.g. `ScheduleType.INTERNAL`, `ClassroomStatus.AVAILABLE`) prevents silent breakage if the stored values ever change and improves type safety.","suggestion_code":" .andWhere('s.scheduleType = :type', { type: ScheduleType.INTERNAL })","existing_code":" .andWhere('s.scheduleType = :type', { type: 'INTERNAL' })"}
{"path":"apps/server/src/dashboard/dashboard-queries.service.ts","start_line":81,"end_line":84,"category":"performance","severity":"medium","content":"This loop executes 6 sequential DB queries (one per month), multiplying latency by 6 round trips. Since the queries are independent, run them in parallel with Promise.all, or better, collapse them into a single query that groups `periodStart` by month. Sequential awaits in loops over independent async work should be avoided.","suggestion_code":" const months = Array.from({ length: 6 }, (_, i) =>\n dayjs.utc(`${currentMonth}-01`).subtract(5 - i, 'month').format('YYYY-MM'),\n );\n return Promise.all(months.map(async (m) => {\n const row = await billRepo\n .createQueryBuilder('b')\n .select('SUM(b.totalAmount)', 'total')\n .where('b.status = :paid', { paid: 'paid' })\n .andWhere('b.periodStart >= :start', { start: `${m}-01` })\n .andWhere('b.periodStart < :end', { end: nextMonth(m) })\n .getRawOne<{ total: string | number | null }>();\n return { month: m, amount: parseFloat(String(row?.total || '0')) };\n }));","existing_code":" for (let i = 5; i >= 0; i--) {\n const m = dayjs.utc(`${currentMonth}-01`).subtract(i, 'month').format('YYYY-MM');\n\n const row = await billRepo"}
{"path":"apps/server/src/dashboard/dashboard-queries.service.ts","start_line":29,"end_line":35,"category":"maintainability","severity":"medium","content":"All four constructor-injected repositories (`attendanceRepo`, `billRepo`, `occRepo`, `expRepo`) are never referenced inside this class — every method takes its repository as an explicit parameter instead (confirmed by callers in dashboard.service.ts passing repos in). This makes the @InjectRepository constructor parameters dead code and hides the actual data-source wiring. Either use `this.attendanceRepo`/`this.billRepo`/etc. inside the methods and drop the parameters, or remove the constructor injection so the explicit parameter-passing pattern is the documented contract.","suggestion_code":"export class DashboardQueriesService {","existing_code":"export class DashboardQueriesService {\n constructor(\n @InjectRepository(AttendanceRecord) private readonly attendanceRepo: Repository<AttendanceRecord>,\n @InjectRepository(Bill) private readonly billRepo: Repository<Bill>,\n @InjectRepository(Occupancy) private readonly occRepo: Repository<Occupancy>,\n @InjectRepository(RoomExpense) private readonly expRepo: Repository<RoomExpense>,\n ) {}"}
{"path":"apps/server/src/dashboard/dashboard-queries.service.ts","start_line":70,"end_line":70,"category":"bug","severity":"low","content":"`rate` has an inconsistent type: it is a string (`toFixed(1)`) when `total > 0` but a number (`0`) otherwise. `getClassAttendanceRanking` in the same file returns the rate as a parsed number, so consumers of this API get different shapes from sibling endpoints. Return a number consistently (e.g. `parseFloat(...toFixed(1))`), matching the other method.","suggestion_code":" rate: d.total > 0 ? parseFloat(((d.present / d.total) * 100).toFixed(1)) : 0,","existing_code":" rate: d.total > 0 ? ((d.present / d.total) * 100).toFixed(1) : 0,"}
{"path":"apps/server/src/dashboard/dashboard-queries.service.ts","start_line":182,"end_line":185,"category":"bug","severity":"low","content":"`room.roomNumber` is selected but only `e.roomId` is in the GROUP BY. Under `ONLY_FULL_GROUP_BY` (MySQL default in recent versions) or PostgreSQL this query fails or returns a non-deterministic roomNumber. Add `room.roomNumber` to the GROUP BY (or group by `room.id`) to be safe and portable.","suggestion_code":" .groupBy('e.roomId')\n .addGroupBy('room.roomNumber')","existing_code":" .select('room.roomNumber', 'roomNumber')\n .addSelect('SUM(e.amount)', 'total')\n .where('room.status != :archived', { archived: 'archived' })\n .groupBy('e.roomId')"}
{"path":"apps/server/src/dashboard/dto/dashboard-query.dto.ts","start_line":5,"end_line":7,"category":"bug","severity":"high","content":"Contradictory validation rules make this property impossible to validate successfully. `@Matches(/^\\d{4}-\\d{2}-\\d{2}$/)` only accepts a bare `YYYY-MM-DD` date, but `@IsISO8601({ strict: true })` (validator.js strict mode) requires a full ISO 8601 timestamp with the `T` separator, time and timezone (e.g. `2024-01-01T00:00:00.000Z`). Because class-validator requires all constraints on a property to pass, no value can ever satisfy both — every request using this DTO will be rejected with a 400. Pick one format: e.g. drop `strict: true` so `@IsISO8601()` accepts plain dates, or align the regex to the strict ISO format.","suggestion_code":" @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601()\n periodStart?: string;","existing_code":" @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601({ strict: true })\n periodStart?: string;"}
{"path":"apps/server/src/dashboard/dto/dashboard-query.dto.ts","start_line":10,"end_line":12,"category":"bug","severity":"high","content":"Same contradiction as `periodStart`: `@Matches(/^\\d{4}-\\d{2}-\\d{2}$/)` allows only a date-only string while `@IsISO8601({ strict: true })` requires a full timestamp with time and timezone, so the two constraints can never pass together. Align the formats (e.g. remove `strict: true` if only `YYYY-MM-DD` is intended).","suggestion_code":" @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601()\n periodEnd?: string;","existing_code":" @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601({ strict: true })\n periodEnd?: string;"}
{"path":"apps/server/src/dashboard/dashboard.service.ts","start_line":345,"end_line":345,"category":"performance","severity":"medium","content":"The `schedQb`/`rentalQb` COUNT(DISTINCT) queries are redundant with the `combinedQb`/`combinedRentalQb` id-list queries below — they run with identical WHERE conditions. `scheduleCount`/`rentalCount` can be derived from `schedIds.length`/`rentalIds.length`, saving two DB round-trips. Consider merging to a single pair of queries.","suggestion_code":null,"existing_code":" const schedResult = await schedQb.getRawOne<{ cnt: string | number | null }>();"}
{"path":"apps/server/src/dashboard/dashboard.service.ts","start_line":175,"end_line":177,"category":"bug","severity":"medium","content":"This only checks `endDate >= today`, so rentals whose `startDate` is still in the future are counted as \"active\" as long as their status is 'active'. Every other rental/schedule query in this file checks both `startDate <= today AND endDate >= today`. Add a `startDate: LessThanOrEqual(todayStr)` condition for consistency.","suggestion_code":null,"existing_code":" const activeRentals = await this.rentalRepo.count({\n where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) },\n });"}
{"path":"apps/server/src/dashboard/dashboard.service.ts","start_line":122,"end_line":122,"category":"bug","severity":"medium","content":"Denominator/numerator mismatch: `classroomCount` counts ALL classrooms (`where: {}`, no status filter) while `occupiedClassrooms` counts any classroom with an active schedule today — no `status: 'available'` filter and no rentals included. This yields a different metric than `getClassroomUtilizationStats`/`getClassroomOccupancy` (which use `status: 'available'` and merge schedules + rentals). Align the definitions so the reported occupancy rate is consistent across endpoints.","suggestion_code":null,"existing_code":" const classroomCount = await this.classroomRepo.count({ where: {} });"}
{"path":"apps/server/src/dashboard/dashboard.service.ts","start_line":132,"end_line":133,"category":"maintainability","severity":"low","content":"Return type is inconsistent: this expression yields a `string` (via `toFixed(1)`) when `classroomCount > 0` but a number `0` otherwise, and unlike `occupancyRate` (fallback `'0.0'`) it is not a string. Normalize to a single type (e.g., always return a string like `'0.0'`) to avoid brittle frontend handling.","suggestion_code":null,"existing_code":" const classroomOccupancyRate =\n classroomCount > 0 ? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) : 0;"}
{"path":"apps/server/src/dashboard/dashboard.service.ts","start_line":246,"end_line":248,"category":"performance","severity":"medium","content":"This loads the full `ClassStudent` rows into memory just to count distinct `studentId`s. For large class sizes this is wasteful — restrict the projection (e.g., `select: ['studentId']`) or, better, push the distinct count down to the DB (e.g., a query builder with `COUNT(DISTINCT studentId)`).","suggestion_code":null,"existing_code":" const classStudents = await this.classStudentRepo.find({\n where: { classId: In(accessibleClassIds), status: 'active' },\n });"}
{"path":"apps/server/src/dashboard/dashboard.service.ts","start_line":158,"end_line":160,"category":"maintainability","severity":"low","content":"Inconsistency with `agentGetDashboardStats`: here the unscoped branch counts all classes including archived ones (`where: {}`), while the agent variant filters `isArchived: false`; also `accessibleClassIds.length` may include archived classes. Use a consistent archive filter for `classCount` in both methods.","suggestion_code":null,"existing_code":" const classCount = accessibleClassIds\n ? accessibleClassIds.length\n : await this.classRepo.count({ where: {} });"}
{"path":"apps/server/src/dashboard/dashboard.service.ts","start_line":317,"end_line":317,"category":"maintainability","severity":"low","content":"The `* 3` weight applied to `rentalCount` is a magic business number with no explanation, making the resulting occupancy metric hard to interpret or adjust. Extract it into a named constant (e.g., `RENTAL_WEIGHT = 3`) and add a comment describing the rationale.","suggestion_code":null,"existing_code":" occupancy: Math.min(((sMap[c.id] || 0) + (rMap[c.id] || 0) * 3) / 7, 1),"}
{"path":"apps/server/src/dashboard/dashboard.service.ts","start_line":168,"end_line":171,"category":"bug","severity":"medium","content":"Suspicious naming/logic: the metric is called `pendingDeposits` but it sums deposits with `status = 'paid'` — which describes collected (non-pending) deposits. If the intent is unrefunded/held deposits, the status filter is likely wrong (should probably target an 'unrefunded'/'pending' status); otherwise rename the field to reflect that it sums paid deposits. Please verify against the deposit status enum.","suggestion_code":null,"existing_code":" const pendingQb = this.depositRepo\n .createQueryBuilder('d')\n .select('SUM(d.amount)', 'total')\n .where('d.status = :paid', { paid: 'paid' });"}
{"path":"apps/server/src/database/database-migrations.runner.ts","start_line":11,"end_line":13,"category":"bug","severity":"low","content":"If `fn(runner)` rejects and `runner.release()` also throws (e.g., a driver-level failure while returning the connection to the pool), the exception thrown by `release()` in the `finally` block will replace the original error from `fn`, losing the real failure cause and making debugging harder. Consider guarding `release()` with its own try/catch (logging the release error) so the original exception propagates. Also note that if `runner.connect()` itself fails, the runner is never released — usually harmless since no connection was established, but worth handling explicitly if the pool driver requires cleanup.","suggestion_code":" } finally {\n try {\n await runner.release();\n } catch (releaseError) {\n // Log releaseError but do not mask the original error from fn.\n }\n }","existing_code":" } finally {\n await runner.release();\n }"}
{"path":"apps/server/src/database/database-migrations.ai.ts","start_line":28,"end_line":30,"category":"bug","severity":"medium","content":"The CREATE TABLE branch omits the `reasoning_effort` column, although the ALTER branch (`desiredColumns`) adds it for pre-existing tables. A fresh install therefore lacks `reasoning_effort` until this migration is run a second time, resulting in different schemas depending on whether the table already existed. Add `reasoning_effort` to the CREATE TABLE statement (or run the same column-sync loop after creating the table).","suggestion_code":" enabled ${boolType} DEFAULT 0,\n timeout_ms INT DEFAULT 30000,\n reasoning_effort VARCHAR(20),\n verified ${boolType} DEFAULT 0,","existing_code":" enabled ${boolType} DEFAULT 0,\n timeout_ms INT DEFAULT 30000,\n verified ${boolType} DEFAULT 0,"}
{"path":"apps/server/src/database/database-migrations.ai.ts","start_line":33,"end_line":34,"category":"bug","severity":"medium","content":"In MySQL, `DEFAULT CURRENT_TIMESTAMP` only applies at INSERT time. Without `ON UPDATE CURRENT_TIMESTAMP`, `updated_at` will never be refreshed by UPDATE statements, so it does not actually track modifications even though it is declared NOT NULL DEFAULT. If DB-managed timestamps are intended, add `ON UPDATE ${datetimeFn}` to `updated_at`; otherwise the column is misleading.","suggestion_code":" created_at DATETIME NOT NULL DEFAULT ${datetimeFn},\n updated_at DATETIME NOT NULL DEFAULT ${datetimeFn} ON UPDATE ${datetimeFn}","existing_code":" created_at DATETIME NOT NULL DEFAULT ${datetimeFn},\n updated_at DATETIME NOT NULL DEFAULT ${datetimeFn}"}
{"path":"apps/server/src/database/database-migrations.ai.ts","start_line":67,"end_line":68,"category":"bug","severity":"medium","content":"These two columns are added as nullable `DATETIME` without NOT NULL/DEFAULT, so existing rows get NULL and future INSERTs must supply explicit values — inconsistent with the CREATE branch which uses `NOT NULL DEFAULT CURRENT_TIMESTAMP`. Use the same definition as the CREATE branch for schema consistency, and consider backfilling existing rows.","suggestion_code":" { name: 'created_at', def: 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP' },\n { name: 'updated_at', def: 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP' },","existing_code":" { name: 'created_at', def: 'DATETIME' },\n { name: 'updated_at', def: 'DATETIME' },"}
{"path":"apps/server/src/database/database-migrations.ai.ts","start_line":42,"end_line":44,"category":"bug","severity":"low","content":"The empty catch swallows every error from unique-index creation, not just the \"index already exists\" case (MySQL error 1061 / ER_DUP_KEYNAME). If the table already contains duplicate `singleton_key` values, the failure is silently ignored and the uniqueness constraint is never enforced. Check the driver error code before ignoring the failure.","suggestion_code":" } catch (err) {\n // MySQL has no IF NOT EXISTS for indexes; ignore only duplicate-key errors (1061)\n const code = (err as { code?: string })?.code;\n if (code !== 'ER_DUP_KEYNAME') {\n throw err;\n }\n }","existing_code":" } catch {\n // Index may already exist; MySQL has no IF NOT EXISTS for indexes\n }"}
{"path":"apps/server/src/database/database-migrations.attendance.ts","start_line":7,"end_line":7,"category":"bug","severity":"medium","content":"The table is created using a check-then-create pattern (`!tableNames.has('attendance_sessions')` followed by a plain `CREATE TABLE` without `IF NOT EXISTS`). Under concurrent multi-instance startup (or if the table is created between the `getTables` check and the `CREATE TABLE`), the second instance will fail with \"table already exists\" and abort startup — unlike `ding_leave_raw` which correctly uses `CREATE TABLE IF NOT EXISTS`. Additionally, this DDL references `class_schedule`/`classes`, which may not exist yet on a fresh database. Suggest using `CREATE TABLE IF NOT EXISTS` for idempotency.","suggestion_code":" CREATE TABLE IF NOT EXISTS ${tableName} (","existing_code":" CREATE TABLE ${tableName} ("}
{"path":"apps/server/src/database/database-migrations.attendance.ts","start_line":56,"end_line":58,"category":"bug","severity":"medium","content":"The `ALTER TABLE attendance_records` statements execute without verifying the table actually exists. If `attendance_records` has not been created yet (fresh database or changed migration ordering), `runner.getTable('attendance_records')` returns undefined and the ALTER will throw, aborting the entire startup. The `class_schedule` branch above correctly guards with a table-existence check first; this branch should do the same (e.g., check `attendanceTable` before altering).","suggestion_code":null,"existing_code":" if (!columnNames.has('schedule_id')) {\n await runner.query('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER');\n }"}
{"path":"apps/server/src/database/database-migrations.attendance.ts","start_line":153,"end_line":157,"category":"bug","severity":"medium","content":"The drop loop removes ALL foreign keys on `schedule_id`/`class_id` — including the `fk_as_schedule_protect`/`fk_as_class_protect` constraints added at the end of this same function. As a result, on every startup these constraints are dropped and then re-added, so the later \"skip if DELETE_RULE = RESTRICT\" check is effectively dead code. This causes redundant DDL (metadata/table locks) on a table that grows over time and leaves a window during startup where `attendance_sessions` has no delete protection. Suggest excluding the two known protect constraint names (or the RESTRICT ones) from the drop loop.","suggestion_code":null,"existing_code":" for (const row of fkRows) {\n try {\n await runner.query(\n `ALTER TABLE attendance_sessions DROP FOREIGN KEY \\`${row.CONSTRAINT_NAME}\\``,\n );"}
{"path":"apps/server/src/database/database-migrations.attendance.ts","start_line":65,"end_line":71,"category":"maintainability","severity":"low","content":"`createIndex` catches and swallows ALL errors, not just \"index already exists\" (MySQL error 1061 / ER_DUP_KEYNAME). Genuine failures — e.g., duplicate rows preventing a UNIQUE index from being created, or the target table missing — are silently ignored and the app starts without the required index. Suggest narrowing the catch to the duplicate-key-name error (and logging/re-throwing everything else) so real problems are not masked during startup.","suggestion_code":null,"existing_code":" const createIndex = async (sql: string) => {\n try {\n await runner.query(sql);\n } catch {\n // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.\n }\n };"}
{"path":"apps/server/src/database/database-migrations.service.ts","start_line":28,"end_line":30,"category":"maintainability","severity":"medium","content":"All 15 migrations are chained in `onApplicationBootstrap` without any error handling or logging, and `this.logger` is never used here (only 5 of the 15 functions receive a logger). If any migration fails, the exception aborts the entire NestJS bootstrap with no indication of which migration failed or why, and the remaining migrations never run. Add per-step try/catch (or at least log before/after each step) so failures are identifiable and actionable, e.g.: `this.logger.log('Ensuring sync state lease columns...')` before each step and a try/catch that logs the failed migration name before rethrowing.","suggestion_code":null,"existing_code":" async onApplicationBootstrap(): Promise<void> {\n await this.ensureAiConfigTable();\n await this.ensureSyncStateLeaseColumns();"}
{"path":"apps/server/src/database/database-migrations.service.ts","start_line":46,"end_line":48,"category":"maintainability","severity":"low","content":"Each of the 15 public wrapper methods is a one-line pass-through that forwards to an imported function of the same name (e.g., `ensureSyncStateLeaseColumns` -> `ensureSyncStateLeaseColumns(this.dataSource)`). This is duplicate boilerplate that can drift from the imported implementations and adds ~30 lines with no added logic. Consider calling the imported functions directly in `onApplicationBootstrap` (or collapsing the wrappers) unless they exist specifically as a test/DI surface.","suggestion_code":null,"existing_code":" async ensureSyncStateLeaseColumns(): Promise<void> {\n return ensureSyncStateLeaseColumns(this.dataSource);\n }"}
{"path":"apps/server/src/database/database-migrations.backfill.ts","start_line":75,"end_line":77,"category":"bug","severity":"medium","content":"Organizations are looked up and re-queried by `name`, which is not unique. If two legacy tenants share the same name, the second tenant will reuse the first tenant's organization (and never create its own, despite having a unique code `ORG_<id>`). The re-query after INSERT can also match a pre-existing organization with the same name (including the host org when a tenant is named like `HOST_ORGANIZATION_NAME`), so `students`/`occupancies`/`classroom_rentals` may be linked to the wrong organization. Use the INSERT result's generated id (e.g., MySQL `insertId`) or query by the unique `code` column (`ORG_${legacy.id}`) for both lookups.","suggestion_code":" external = (\n (await runner.query('SELECT * FROM organizations WHERE code = ? LIMIT 1', [`ORG_${String(legacy.id)}`])) as OrganizationRow[]\n )[0];","existing_code":" external = (\n (await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])) as OrganizationRow[]\n )[0];"}
{"path":"apps/server/src/database/database-migrations.backfill.ts","start_line":81,"end_line":86,"category":"bug","severity":"medium","content":"`.catch(() => undefined)` silently swallows every UPDATE error. If a legacy schema lacks one of the referenced columns (e.g., `tenant_id`), this tenant-scoped update fails quietly and the later fallback `UPDATE students SET organization_id = ? WHERE organization_id IS NULL` will then assign the host organization to all remaining rows, silently producing incorrect data associations. Log a warning with the error (e.g., `logger.warn`) instead of discarding it so migration failures are visible and diagnosable.","suggestion_code":null,"existing_code":" await runner\n .query(\n 'UPDATE students SET organization_id = ? WHERE organization_id IS NULL AND tenant_id = ?',\n [external.id, legacy.id],\n )\n .catch(() => undefined);"}
{"path":"apps/server/src/database/database-migrations.backfill.ts","start_line":123,"end_line":128,"category":"bug","severity":"low","content":"The final fallback for `classroom_rentals` only backfills `lessor_organization_id`, leaving `lessee_organization_id` NULL for rows not matched in the tenant loop — whereas the tenant loop sets both sides (`lessee = external, lessor = host`). This asymmetry means some rentals end up half-backfilled. If full backfilling is intended, also set the lessee to the host here (or document why a NULL lessee is acceptable).","suggestion_code":null,"existing_code":" if (tableNames.has('classroom_rentals')) {\n await runner.query(\n 'UPDATE classroom_rentals SET lessor_organization_id = ? WHERE lessor_organization_id IS NULL',\n [host.id],\n );\n }"}
{"path":"apps/server/src/database/database-migrations.schema.ts","start_line":155,"end_line":156,"category":"bug","severity":"medium","content":"These UPDATEs are data backfills that run on every startup (this function is an idempotent startup migration). They can corrupt legitimate data on subsequent boots: (1) a partially paid bill (0 < paid_amount < total_amount, status <> 'paid') gets outstanding_amount forcibly reset to total_amount, ignoring paid_amount; (2) a legitimately zero-outstanding non-paid bill (e.g., fully refunded/voided or zero-value) is clobbered back to total_amount each boot; (3) if paid bills are later refunded, the second UPDATE keeps overwriting paid_amount back to total_amount. Consider computing outstanding from paid_amount (`SET outstanding_amount = total_amount - paid_amount`) and/or gating this backfill to run only when the column was just added (i.e., inside the `if (!columns.has(...))` branch), not on every boot.","suggestion_code":null,"existing_code":" await runner.query(\"UPDATE bills SET outstanding_amount = total_amount WHERE outstanding_amount = 0 AND status <> 'paid'\");\n await runner.query(\"UPDATE bills SET paid_amount = total_amount, outstanding_amount = 0 WHERE status = 'paid'\");"}
{"path":"apps/server/src/database/database-migrations.schema.ts","start_line":137,"end_line":139,"category":"bug","severity":"medium","content":"Unlike the `createIndex` helper used for attendance_devices (which guards against failures to keep startup idempotent), this unique index is created without any error handling. If legacy `room_expenses` rows contain duplicate non-NULL `import_key` values, the CREATE UNIQUE INDEX will throw and abort startup. Wrap it in a try/catch with logging (and/or check for duplicates first) to match the pattern used elsewhere and keep startup resilient.","suggestion_code":null,"existing_code":" await runner.query(\n 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)',\n );"}
{"path":"apps/server/src/database/database-migrations.schema.ts","start_line":69,"end_line":75,"category":"maintainability","severity":"medium","content":"All index-creation errors are silently swallowed with no logging. When the unique index fails (e.g., existing rows share duplicate `device_sn` values, including rows backfilled with the DEFAULT '' value after `ADD COLUMN`), the failure is invisible, uniqueness is silently never enforced, and the statement is retried on every startup. At minimum log a warning so schema drift / duplicate data is surfaced.","suggestion_code":null,"existing_code":" const createIndex = async (sql: string) => {\n try {\n await runner.query(sql);\n } catch {\n // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.\n }\n };"}
{"path":"apps/server/src/database/database-migrations.schema.ts","start_line":152,"end_line":154,"category":"maintainability","severity":"low","content":"The \"add columns if missing\" pattern (fetch table -> build Set of column names -> loop ALTER TABLE for missing columns) is duplicated in `ensureSyncStateLeaseColumns`, `ensureStudentProfileCollegeColumns`, `ensureAttendanceDevicesSchema`, `ensureStudentWalletSchema` (x3), and `cleanupDepositRefundColumns`. Extracting a shared helper such as `ensureColumns(runner, tableName, additions: Array<[string, string]>)` would remove the repeated logic and reduce the risk of divergence (e.g., the unguarded unique-index vs guarded createIndex inconsistency).","suggestion_code":null,"existing_code":" for (const [name, definition] of additions) {\n if (!columns.has(name)) await runner.query(`ALTER TABLE bills ADD COLUMN ${name} ${definition}`);\n }"}
{"path":"apps/server/src/entities/attendance-period-config.entity.ts","start_line":10,"end_line":20,"category":"maintainability","severity":"medium","content":"All columns here are implicitly nullable because TypeORM only sets NOT NULL when `nullable: false` is specified. The service layer treats `periodKey`, `label`, `startTime`, and `endTime` as mandatory (e.g. `saveAttendancePeriodConfigs` throws when the key/label are empty, and period matching depends on `periodKey`), and `sortOrder`/`enabled` always receive values. Since the DB schema does not enforce NOT NULL, a direct insert/update (or a bug in an upstream caller) can silently persist rows with NULL keys/times, breaking period matching and ordering. Add `nullable: false` to these columns (and consider `default: 0`/`default: true` already set for `sortOrder`/`enabled`).","suggestion_code":" @Column({ name: 'period_key', type: 'varchar', length: 40, nullable: false })\n periodKey: string;\n\n @Column({ type: 'varchar', length: 40, nullable: false })\n label: string;\n\n @Column({ name: 'start_time', type: 'varchar', length: 5, nullable: false })\n startTime: string;\n\n @Column({ name: 'end_time', type: 'varchar', length: 5, nullable: false })\n endTime: string;","existing_code":" @Column({ name: 'period_key', type: 'varchar', length: 40 })\n periodKey: string;\n\n @Column({ type: 'varchar', length: 40 })\n label: string;\n\n @Column({ name: 'start_time', type: 'varchar', length: 5 })\n startTime: string;\n\n @Column({ name: 'end_time', type: 'varchar', length: 5 })\n endTime: string;"}
{"path":"apps/server/src/entities/attendance-period-config.entity.ts","start_line":16,"end_line":20,"category":"maintainability","severity":"low","content":"`start_time`/`end_time` are stored as `varchar(5)`, which fits the \"HH:MM\" format used by the default configs. However, nothing at the schema level prevents invalid or inconsistently formatted values (e.g. \"9:5\", \"09:5\", \"09:00:00\" would be silently truncated). Consider adding a CHECK constraint (e.g. `start_time REGEXP '^([01][0-9]|2[0-3]):[0-5][0-9]$'`) or validating the format in the DTO with a `@Matches(...)` decorator so data integrity is enforced even when a new write path bypasses the service validation.","suggestion_code":null,"existing_code":" @Column({ name: 'start_time', type: 'varchar', length: 5 })\n startTime: string;\n\n @Column({ name: 'end_time', type: 'varchar', length: 5 })\n endTime: string;"}
{"path":"apps/server/src/entities/archive-attachment.entity.ts","start_line":24,"end_line":25,"category":"bug","severity":"medium","content":"Nullable DB columns are typed as non-nullable in TypeScript (`category`, `fileName`, `filePath`, `fileSize`, `mimeType` are all `nullable: true`). When the DB row contains NULL, TypeORM returns `null` and downstream code like `attachment.fileName.toUpperCase()` will throw at runtime even though TypeScript sees a `string`. Type these properties as `string | null` (and `number | null` for `fileSize`) to force callers to perform null checks.","suggestion_code":" @Column({ length: 50, nullable: true })\n category: string | null;","existing_code":" @Column({ length: 50, nullable: true })\n category: string;"}
{"path":"apps/server/src/entities/archive-attachment.entity.ts","start_line":20,"end_line":22,"category":"performance","severity":"medium","content":"`eager: true` forces TypeORM to always JOIN and load the full `Student` row on every `ArchiveAttachment` query (including list queries that only need the attachment itself), which can cause unnecessary data fetching and performance degradation in bulk operations. Prefer removing `eager` and explicitly requesting the relation via query `relations` options. Also, no index is defined on `student_id`, so add `@Index()` on the `studentId` column if lookups by student are frequent.","suggestion_code":" @ManyToOne(() => Student)\n @JoinColumn({ name: 'student_id' })\n student: Student;","existing_code":" @ManyToOne(() => Student, { eager: true })\n @JoinColumn({ name: 'student_id' })\n student: Student;"}
{"path":"apps/server/src/entities/archive-attachment.entity.ts","start_line":33,"end_line":34,"category":"bug","severity":"low","content":"`integer` maps to a 32-bit signed DB column, which overflows for files larger than ~2GB and would throw an out-of-range error when saving. If large archives are expected, use `bigint` (with a matching `number`/`bigint` property type).","suggestion_code":" @Column({ name: 'file_size', type: 'bigint', nullable: true })\n fileSize: number;","existing_code":" @Column({ name: 'file_size', type: 'integer', nullable: true })\n fileSize: number;"}
{"path":"apps/server/src/entities/archive-attachment.entity.ts","start_line":37,"end_line":40,"category":"style","severity":"low","content":"Extra blank line between the `mimeType` column and the `status` column; remove it for consistency with the rest of the file.","suggestion_code":" mimeType: string;\n\n @Column({ type: 'varchar', length: 20, default: 'active' })","existing_code":" mimeType: string;\n\n\n @Column({ type: 'varchar', length: 20, default: 'active' })"}
{"path":"apps/server/src/entities/attendance-device.entity.ts","start_line":39,"end_line":39,"category":"maintainability","severity":"low","content":"Redundant union type: `AttendanceDeviceStatus` already has exactly the values `'active'` and `'disabled'`, so `| 'active' | 'disabled'` adds no valid values while weakening the type contract (any plain string literal still type-checks against this column, bypassing the enum). This redundancy is also copied into the controller (`@Query('status') status?: AttendanceDeviceStatus | 'active' | 'disabled'`) and service signatures. Use `AttendanceDeviceStatus` alone so all consumers get the centralized enum type.","suggestion_code":"status: AttendanceDeviceStatus;","existing_code":"status: AttendanceDeviceStatus | 'active' | 'disabled';"}
{"path":"apps/server/src/entities/attendance-device.entity.ts","start_line":38,"end_line":39,"category":"maintainability","severity":"medium","content":"The `status` column is stored as plain `varchar`, so invalid values (e.g. 'paused', 'enabled') can be written directly at the DB layer and bypass the enum entirely. Since `AttendanceDeviceStatus` already defines the allowed values, use TypeORM's `enum` column type so the database enforces data integrity, not just the application type.","suggestion_code":"@Column({\n type: 'enum',\n enum: AttendanceDeviceStatus,\n enumName: 'attendance_device_status',\n default: AttendanceDeviceStatus.ACTIVE,\n })\n status: AttendanceDeviceStatus;","existing_code":"@Column({ type: 'varchar', length: 20, default: AttendanceDeviceStatus.ACTIVE })\n status: AttendanceDeviceStatus | 'active' | 'disabled';"}
{"path":"apps/server/src/deposits/dto/deposit.dto.ts","start_line":20,"end_line":23,"category":"security","severity":"medium","content":"`studentIds` is validated only as a non-empty integer array with no upper bound. A client can submit an arbitrarily large array (e.g., hundreds of thousands of IDs), forcing the service to allocate memory and run a huge batch operation — a resource-exhaustion/DoS vector. Add `@ArrayMaxSize` (e.g., 100) to cap the batch size and import it from class-validator.","suggestion_code":" @IsArray()\n @ArrayNotEmpty()\n @ArrayMaxSize(100)\n @IsInt({ each: true })\n studentIds: number[];","existing_code":" @IsArray()\n @ArrayNotEmpty()\n @IsInt({ each: true })\n studentIds: number[];"}
{"path":"apps/server/src/deposits/dto/deposit.dto.ts","start_line":59,"end_line":62,"category":"bug","severity":"low","content":"Both `paidDate` and `status` are optional in this update DTO, so an empty payload `{}` passes validation and silently becomes a no-op update unless the service explicitly guards against it. Consider enforcing that at least one field is present (e.g., a class-level `@ValidateIf`/custom validator). Also add `@IsString()` on `status` so the type constraint is explicit rather than relying on `IsIn`'s strict comparison.","suggestion_code":" @IsOptional()\n @IsString()\n @IsIn(['pending', 'paid', 'overdue'])\n status?: string;","existing_code":"export class UpdateDepositInstallmentDto {\n @IsOptional()\n @IsDateString()\n paidDate?: string;"}
{"path":"apps/server/src/deposits/dto/deposit.dto.ts","start_line":4,"end_line":5,"category":"bug","severity":"low","content":"`studentId` only requires `@IsInt()`, so `0` and negative integers pass validation and would be meaningless DB IDs. Add `@Min(1)` (and `@Min(1, { each: true })` on `studentIds` in `BatchCreateDepositDto`) to reject non-positive IDs.","suggestion_code":" @IsInt()\n @Min(1)\n studentId: number;","existing_code":" @IsInt()\n studentId: number;"}
{"path":"apps/server/src/deposits/dto/deposit.dto.ts","start_line":19,"end_line":27,"category":"maintainability","severity":"low","content":"`BatchCreateDepositDto` duplicates the `amount`, `paidDate`, and `notes` fields (and their validators) from `CreateDepositDto`. Consider extracting a shared base DTO (e.g., `BaseDepositDto`) and extending it so the validation rules stay consistent when amounts/dates change.","suggestion_code":null,"existing_code":"export class BatchCreateDepositDto {\n @IsArray()\n @ArrayNotEmpty()\n @IsInt({ each: true })\n studentIds: number[];\n\n @IsNumber({ maxDecimalPlaces: 2 })\n @Min(0.01)\n amount: number;"}
{"path":"apps/server/src/database/date-normalization.ts","start_line":12,"end_line":12,"category":"bug","severity":"medium","content":"Timezone conversion can silently change the date. `dayjs(date).utcOffset(8)` re-renders the parsed instant in UTC+8, so an input like `2026-08-09T20:00:00Z` is normalized to `2026-08-10` — the date rolls over even though the caller supplied an 08-09 date. Worse, for naive strings without an offset (e.g. `2026-08-09T12:00:00`), `new Date` interprets them in the *server's* local timezone, so the same wall-clock input yields different results depending on where the server runs. If the intent is only to strip the time from the original value, return `isoPrefix` directly; otherwise normalize every input to a fixed zone (e.g. `dayjs.utc(value)` / `dayjs(value).utcOffset(8)` consistently) and document the rollover semantics. Also consider extracting the hardcoded offset `8` into a named constant.`","suggestion_code":null,"existing_code":" if (!Number.isNaN(date.getTime())) return dayjs(date).utcOffset(8).format('YYYY-MM-DD');"}
{"path":"apps/server/src/database/date-normalization.ts","start_line":7,"end_line":7,"category":"bug","severity":"low","content":"`DATE_ONLY_PATTERN` only checks the shape, not the validity of the date — values like `2026-13-40` or `2026-02-31` pass through unchanged and will be persisted. Since the fast path is returned without any validation, consider strict parsing (e.g. `dayjs(value, 'YYYY-MM-DD', true).isValid()`) or validating the date components before returning.`, the fast path returns the value without any validation, so impossible dates like `2026-13-40` or `2026-02-31` pass through unchanged and get stored. Consider strict parsing (e.g. `dayjs(value, 'YYYY-MM-DD', true).isValid()`) or component-level validation before returning.`","suggestion_code":null,"existing_code":" if (DATE_ONLY_PATTERN.test(value)) return value;"}
{"path":"apps/server/src/entities/attendance-session.entity.ts","start_line":39,"end_line":40,"category":"maintainability","severity":"medium","content":"The business status value 'in_progress' is hardcoded as a raw magic string. This is a business-related constant that can easily drift from service-side code (e.g., 'completed', 'cancelled' used elsewhere), leading to silent data inconsistencies. Recommend defining a TS enum/union type (e.g., `enum AttendanceSessionStatus`) and using it for both the column type and default.","suggestion_code":"@Column({ type: 'varchar', length: 20, default: AttendanceSessionStatus.IN_PROGRESS })\n status: AttendanceSessionStatus;","existing_code":"@Column({ length: 20, default: 'in_progress' })\n status: string;"}
{"path":"apps/server/src/entities/attendance-session.entity.ts","start_line":36,"end_line":37,"category":"bug","severity":"medium","content":"Type mismatch: the column is declared as `type: 'date'` but the property is typed as `string`. Depending on the DB driver (e.g., PostgreSQL returns JS `Date` objects for `date` columns), the runtime value may be a `Date`, breaking string operations/comparisons that assume 'YYYY-MM-DD'. Align the property type with the actual driver return type or use a consistent type across the codebase.","suggestion_code":null,"existing_code":"@Column({ name: 'lesson_date', type: 'date' })\n lessonDate: string;"}
{"path":"apps/server/src/entities/bill-item.entity.ts","start_line":33,"end_line":34,"category":"bug","severity":"medium","content":"TypeORM (and the underlying MySQL/pg drivers) return `decimal` columns as strings, not numbers. Declaring `roomTotalAmount` as `number` is misleading: consumers will receive a string at runtime, which can silently break arithmetic comparisons or validation. Either declare these as `string` or use a column transformer (e.g., a decimal-to-number transformer) so the TypeScript type matches the actual runtime value.","suggestion_code":"@Column({ name: 'room_total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true, transformer: { to: (v: number) => v, from: (v: string) => parseFloat(v) } })\n roomTotalAmount: number;","existing_code":"@Column({ name: 'room_total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })\n roomTotalAmount: number;"}
{"path":"apps/server/src/entities/bill-item.entity.ts","start_line":36,"end_line":37,"category":"bug","severity":"medium","content":"Same issue as `roomTotalAmount`: `decimal` columns are returned as strings by TypeORM drivers, so `studentAmount` will not actually be a `number` at runtime. Apply the same transformer/typing fix for consistency.","suggestion_code":null,"existing_code":"@Column({ name: 'student_amount', type: 'decimal', precision: 10, scale: 2, nullable: true })\n studentAmount: number;"}
{"path":"apps/server/src/entities/bill-item.entity.ts","start_line":18,"end_line":19,"category":"bug","severity":"medium","content":"The column is `nullable: true` but the property type is `number` (non-nullable). This is inconsistent with `roomExpenseId`/`personalExpenseId` above, which correctly use `number | null`. After loading an entity, `roomId` can legitimately be `null`, and code that assumes it is a number may throw a null-pointer-style error. Type it as `number | null` (and consider adding `type: 'integer'` for consistency with the other FK columns).","suggestion_code":"@Column({ name: 'room_id', type: 'integer', nullable: true })\n roomId: number | null;","existing_code":"@Column({ name: 'room_id', nullable: true })\n roomId: number;"}
{"path":"apps/server/src/entities/bill-item.entity.ts","start_line":21,"end_line":22,"category":"bug","severity":"low","content":"`expenseType` is marked `nullable: true` but typed as non-nullable `string`. The runtime value can be `null`, so the property type should include `null` to avoid null-reference errors at call sites. The same inconsistency applies to `description`, `days`, and `totalRoomDays` in this entity.","suggestion_code":"@Column({ name: 'expense_type', length: 20, nullable: true })\n expenseType: string | null;","existing_code":"@Column({ name: 'expense_type', length: 20, nullable: true })\n expenseType: string;"}
{"path":"apps/server/src/deposits/deposits.controller.ts","start_line":168,"end_line":168,"category":"bug","severity":"medium","content":"`void this.notificationsService.create(...)` discards the returned promise without awaiting or attaching a `.catch()`. The surrounding `try/catch` cannot catch an async rejection here because the promise isn't awaited — a rejection from `notificationsService.create` becomes an **unhandled promise rejection**, which in modern Node.js (default `--unhandled-rejections=throw`) can crash the process. This defeats the stated intent of the comment (`通知失败不影响主流程`). Fix: await it (keeping it inside the try/catch) or add `.catch(() => {})`.","suggestion_code":" await this.notificationsService.create({ recipientIds: [student.userId], type, title, content });","existing_code":" void this.notificationsService.create({ recipientIds: [student.userId], type, title, content });"}
{"path":"apps/server/src/deposits/deposits.controller.ts","start_line":92,"end_line":92,"category":"performance","severity":"low","content":"`create` (and likewise `refund`) `await notifyDeposit(...)` before returning, which serializes an extra DB lookup (`studentRepo.findOne`) plus notification creation into the critical write-path latency of the response. Since the notification is explicitly fire-and-forget, consider performing the student lookup + notification asynchronously (with proper error handling) or after returning the response, so deposit creation isn't blocked by it.","suggestion_code":null,"existing_code":" await this.notifyDeposit(dto.studentId, 'deposit_due', '押金待缴', `您有一笔押金待缴纳,金额: ¥${dto.amount}`);"}
{"path":"apps/server/src/deposits/deposits.controller.ts","start_line":109,"end_line":113,"category":"maintainability","severity":"low","content":"The installment endpoints use an ad-hoc inline request type (`user?: { id; username }`, `headers?: Record<string, string>`) while all other handlers use the shared `AuthenticatedRequest` interface. This is inconsistent and easy to drift — e.g., `logAudit`/`extractRequestInfo` actually read `headers` as `Record<string, string | string[] | undefined>`. Use `AuthenticatedRequest` here too for consistency.","suggestion_code":" @Request() req: AuthenticatedRequest,","existing_code":" async addInstallment(\n @Param('id', ParseIntPipe) id: number,\n @Body() body: CreateDepositInstallmentDto,\n @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },\n ) {"}
{"path":"apps/server/src/entities/class-schedule.entity.ts","start_line":71,"end_line":72,"category":"maintainability","severity":"medium","content":"The `ScheduleType` enum is declared at the top of the file but never used — the `schedule_type` column falls back to a plain `string` with a hardcoded `'INTERNAL'` default. This means invalid values (e.g., 'RENTALX') can be persisted with no compile-time or DB-level validation, and the enum becomes dead code. Use the enum for both the column definition and the property type.","suggestion_code":" @Column({ name: 'schedule_type', type: 'enum', enum: ScheduleType, default: ScheduleType.INTERNAL })\n scheduleType: ScheduleType;","existing_code":" @Column({ name: 'schedule_type', length: 20, default: 'INTERNAL' })\n scheduleType: string;"}
{"path":"apps/server/src/entities/class-schedule.entity.ts","start_line":77,"end_line":78,"category":"maintainability","severity":"low","content":"The `status` column stores a hardcoded business string `'active'` with no validation. Invalid statuses can silently be written to the DB. Consider defining a status enum/constant set and validating it (e.g., DB enum or DTO-level validation) to keep business states consistent.","suggestion_code":null,"existing_code":" @Column({ name: 'status', length: 20, default: 'active' })\n status: string;"}
{"path":"apps/server/src/entities/class-schedule.entity.ts","start_line":80,"end_line":81,"category":"bug","severity":"low","content":"`notes` is declared `nullable: true` in the DB but typed as non-nullable `string`. Downstream code may assume it is always a string and hit null-pointer issues. The TypeScript type should reflect the column nullability: `notes: string | null;`","suggestion_code":" @Column({ name: 'notes', type: 'text', nullable: true })\n notes: string | null;","existing_code":" @Column({ name: 'notes', type: 'text', nullable: true })\n notes: string;"}
{"path":"apps/server/src/entities/class-schedule.entity.ts","start_line":37,"end_line":39,"category":"maintainability","severity":"low","content":"The `classroom` relation is typed as `unknown`, which discards all type safety for a forward-referenced entity (unlike `class`/`teacher`, which import proper types). Import a `Classroom` type and declare `classroom: Classroom | null;` so consumers get compile-time checks.","suggestion_code":null,"existing_code":" @ManyToOne('Classroom')\n @JoinColumn({ name: 'classroom_id' })\n classroom: unknown;"}
{"path":"apps/server/src/entities/class-schedule.entity.ts","start_line":44,"end_line":48,"category":"other","severity":"low","content":"No data-integrity constraint ensures `end_time` is after `start_time` (or `end_date` >= `start_date`). A schedule with end before start would silently be persisted and could break attendance/time logic. Consider adding `@Check` constraints (e.g., `@Check('end_time > start_time')`) similar to the existing `week_day` check.","suggestion_code":null,"existing_code":" @Column({ name: 'start_time', length: 5 })\n startTime: string;\n\n @Column({ name: 'end_time', length: 5 })\n endTime: string;"}
{"path":"apps/server/src/entities/attendance-record.entity.ts","start_line":31,"end_line":32,"category":"bug","severity":"medium","content":"`class_id` is declared `nullable: true` and the relation uses `onDelete: 'SET NULL'`, so `classId` will be `null` at runtime (e.g. after a class is deleted or when no class is assigned). It is typed as `number`, which defeats TypeScript null-safety and can cause runtime null-pointer errors in service code that assumes a non-null value. Use `number | null` for consistency with `scheduleId`/`attendanceSessionId` above.","suggestion_code":" @Column({ name: 'class_id', type: 'integer', nullable: true })\n classId: number | null;","existing_code":" @Column({ name: 'class_id', type: 'integer', nullable: true })\n classId: number;"}
{"path":"apps/server/src/entities/attendance-record.entity.ts","start_line":19,"end_line":19,"category":"bug","severity":"medium","content":"This unique composite index does not enforce uniqueness when `attendance_session_id` is NULL: in standard SQL (MySQL/PostgreSQL), NULL values are treated as distinct in unique indexes, so the same `student_id` can be inserted multiple times with a NULL session (e.g. manual/standalone records). If the business rule is \"one record per student per attendance session\", `attendanceSessionId` should be non-nullable for every record, or uniqueness for session-less records must be enforced in application logic. Note that the relation's `onDelete: 'SET NULL'` also converts rows to NULL sessions, which bypasses this constraint.","suggestion_code":null,"existing_code":"@Index(['attendanceSessionId', 'studentId'], { unique: true })"}
{"path":"apps/server/src/entities/attendance-record.entity.ts","start_line":34,"end_line":36,"category":"maintainability","severity":"low","content":"`class` is a reserved word in JavaScript. Although legal as an object property name, it breaks destructuring (`const { class } = record` is a SyntaxError) and can confuse tooling/readers. Consider renaming the property (e.g. `classEntity`) while keeping the DB column `class_id` via the existing `@JoinColumn`/`name` mapping.","suggestion_code":null,"existing_code":" @ManyToOne(() => Class, { onDelete: 'SET NULL', nullable: true })\n @JoinColumn({ name: 'class_id' })\n class: Class;"}
{"path":"apps/server/src/entities/attendance-record.entity.ts","start_line":67,"end_line":68,"category":"bug","severity":"low","content":"`default: 'manual'` only affects the generated DDL. TypeORM does not populate the in-memory property when an entity is instantiated without `source`, and after `save()` in MySQL the DB-applied default may not be reflected in the returned entity. Code reading `record.source` immediately after creation can get `undefined`. Initialize the property so the runtime value matches the DB default.","suggestion_code":" @Column({ name: 'source', length: 20, default: 'manual' })\n source: string = 'manual';","existing_code":" @Column({ name: 'source', length: 20, default: 'manual' })\n source: string;"}
{"path":"apps/server/src/entities/bed.entity.ts","start_line":30,"end_line":31,"category":"maintainability","severity":"medium","content":"The `status` column is stringly-typed with a hardcoded business default `'available'`. The same literal is compared/assigned in many places (e.g., `bed.status !== 'available'` in occupancies/room-bed-locker services, and the bed DTO validates `@IsEnum(['available','occupied','maintenance'])`), so a typo in any one place would silently break business logic. Consider defining a `BedStatus` enum/constant (the codebase already uses `ClassroomStatus` this way) and using it for both the default value and the property type.","suggestion_code":null,"existing_code":" @Column({ type: 'varchar', length: 20, default: 'available' })\n status: string;"}
{"path":"apps/server/src/entities/bed.entity.ts","start_line":23,"end_line":25,"category":"maintainability","severity":"low","content":"The `room_id` column is NOT NULL in the schema (see migration: `room_id` int NOT NULL), but `@ManyToOne` defaults to `nullable: true`. This makes the relation metadata inconsistent with the actual column constraint and suggests `room` can be null when it cannot. Set `nullable: false` on the relation to keep the metadata aligned with the schema.","suggestion_code":null,"existing_code":" @ManyToOne(() => Room, { onDelete: 'CASCADE' })\n @JoinColumn({ name: 'room_id' })\n room: Room;"}
{"path":"apps/server/src/entities/bed.entity.ts","start_line":13,"end_line":13,"category":"style","severity":"low","content":"The `@Unique` decorator is separated from the class declaration by a blank line. It still applies to the `Bed` class, but this formatting is misleading (it looks like an orphaned decorator) and is likely to be flagged by formatters/linters. Remove the blank line between the two class decorators.","suggestion_code":null,"existing_code":"@Unique(['roomId', 'bedNumber'])"}
{"path":"apps/server/src/deposits/deposits.service.ts","start_line":239,"end_line":240,"category":"bug","severity":"high","content":"Potential data loss: `findOne` here matches ANY deposit of the student, including archived ones. Because `purge()` refuses to permanently delete deposits that carry `refundAmount > 0`, archived records with refund history persist. A subsequent `create`/`batchCreate` for that student will then reopen the archived record, overwrite `status` to 'paid', and null out `refundDate/refundAmount/refundedBy/refundedAt` — permanently erasing the historical refund/audit trail. Additionally this check-then-act (findOne followed by save/insert) is not atomic; two concurrent creates for the same new student can both pass the `!existing` branch and produce duplicate deposit rows. Suggest excluding archived records (e.g. `where: { studentId, status: Not('archived') }` and import `Not` from typeorm), or always inserting a fresh record when an archived one exists.","suggestion_code":" const existing = await this.repo.findOne({\n where: { studentId: dto.studentId, status: Not('archived') },\n });\n if (existing) {","existing_code":" const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });\n if (existing) {"}
{"path":"apps/server/src/deposits/deposits.service.ts","start_line":271,"end_line":272,"category":"bug","severity":"medium","content":"No deposit-status guard: installments can be added to a deposit that is already fully refunded (`refunded`, amount=0) or archived, which is inconsistent business logic (staged payments on an already refunded/closed deposit). Add a guard before creating the installment.","suggestion_code":" const deposit = await this.repo.findOne({ where: { id: depositId } });\n if (!deposit) throw new NotFoundException('押金记录不存在');\n if (deposit.status === 'refunded' || deposit.status === 'archived') {\n throw new BadRequestException('已退款或已归档的押金不能新增分期');\n }","existing_code":" const deposit = await this.repo.findOne({ where: { id: depositId } });\n if (!deposit) throw new NotFoundException('押金记录不存在');"}
{"path":"apps/server/src/deposits/deposits.service.ts","start_line":132,"end_line":133,"category":"performance","severity":"medium","content":"The loop sequentially awaits `create` for each student, even though these operations are independent (studentIds are deduplicated and don't share state). This serializes N round-trips (student lookup + deposit find + save each) and makes batch creation O(N) in wall-clock time. Use `Promise.all` to run them in parallel.","suggestion_code":" const results = await Promise.all(\n studentIds.map((studentId) => this.create({\n studentId,\n amount,\n paidDate: dto.paidDate,\n notes: dto.notes,\n }, userId)),\n );","existing_code":" for (const studentId of studentIds) {\n results.push(await this.create({"}
{"path":"apps/server/src/deposits/deposits.service.ts","start_line":66,"end_line":66,"category":"bug","severity":"low","content":"`occupancyRepo` is declared optional (`?`) in the constructor but then force-unwrapped with `!` here. If it is ever undefined (e.g., test setup or a code path where the provider isn't injected), this throws a raw `TypeError` instead of a controlled error. Since `getEligibleStudents` uses it unconditionally, make it a required constructor parameter (drop `?`), or add an explicit null guard with a clear error message.","suggestion_code":" if (!this.occupancyRepo) throw new Error('Occupancy repository is not available');\n const qb = this.occupancyRepo","existing_code":" const qb = this.occupancyRepo!"}
{"path":"apps/server/src/deposits/deposits.service.ts","start_line":196,"end_line":199,"category":"security","severity":"low","content":"User-supplied `keyword` is interpolated into a LIKE pattern without escaping. Characters `%` and `_` act as wildcards, so a keyword like `%` matches every student, and `_` matches arbitrary characters — leading to unintended broad matches and slight information exposure. Escape `\\`, `%`, `_` in the keyword and add an `ESCAPE` clause (or sanitize the keyword to a plain substring match).","suggestion_code":" const escaped = query.keyword.replace(/[\\\\%_]/g, (ch) => `\\\\${ch}`);\n qb.andWhere(\n '(student.name LIKE :keyword ESCAPE :escape OR student.studentNo LIKE :keyword ESCAPE :escape)',\n { keyword: `%${escaped}%`, escape: '\\\\' },\n );","existing_code":" qb.andWhere(\n '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',\n { keyword: `%${query.keyword}%` },\n );"}
{"path":"apps/server/src/entities/class-student.entity.ts","start_line":29,"end_line":31,"category":"bug","severity":"high","content":"Inconsistent referential behavior: the `class` relation uses `onDelete: 'CASCADE'`, but the `student` relation has no `onDelete` option (defaults to NO ACTION/RESTRICT at the DB level). Since `student_id` is NOT NULL, deleting a Student that still has rows in `class_student` will throw a foreign-key constraint error at runtime, while deleting a Class silently cascades. Either add `onDelete: 'CASCADE'` here (appropriate for a join table) or align the behavior deliberately (e.g., soft-delete students).","suggestion_code":" @ManyToOne(() => Student, { onDelete: 'CASCADE' })\n @JoinColumn({ name: 'student_id' })\n student: Student;","existing_code":" @ManyToOne(() => Student)\n @JoinColumn({ name: 'student_id' })\n student: Student;"}
{"path":"apps/server/src/entities/class-student.entity.ts","start_line":33,"end_line":34,"category":"bug","severity":"medium","content":"Type mismatch: `joinDate`/`leaveDate` are declared as `string | null`, but TypeORM maps `date` columns to `Date` objects at runtime (matching `createdAt: Date` in this same entity). Returning these as strings can cause bugs when the values are passed to `Date` APIs or serialized. Declare them as `Date | null` instead.","suggestion_code":" @Column({ name: 'join_date', type: 'date', nullable: true })\n joinDate: Date | null;","existing_code":" @Column({ name: 'join_date', type: 'date', nullable: true })\n joinDate: string | null;"}
{"path":"apps/server/src/entities/class-student.entity.ts","start_line":23,"end_line":24,"category":"maintainability","severity":"low","content":"Using `class` as a property name is risky in JS/TS because it is a reserved word: destructuring (`const { class } = ...`) is a syntax error, and some serialization/tooling handles it poorly. Consider renaming to e.g. `classEntity`/`clazz` or using a non-reserved name to improve maintainability.","suggestion_code":null,"existing_code":" @JoinColumn({ name: 'class_id' })\n class: Class;"}
{"path":"apps/server/src/entities/class-student.entity.ts","start_line":26,"end_line":27,"category":"performance","severity":"low","content":"The unique constraint `@Unique(['classId', 'studentId'])` creates an index on `(class_id, student_id)`, which does not efficiently serve queries that filter by `student_id` alone (e.g., \"all classes of a student\"). Add a dedicated index on `student_id` (or use `@Index` on the column) if such lookups are expected.","suggestion_code":null,"existing_code":" @Column({ name: 'student_id', type: 'integer' })\n studentId: number;"}
{"path":"apps/server/src/entities/bill.entity.ts","start_line":27,"end_line":28,"category":"bug","severity":"high","content":"TypeORM's MySQL driver returns DECIMAL columns as strings at runtime, not numbers. These money fields (sharedAmount, personalAmount, totalAmount, paidAmount, outstandingAmount) are declared as `number`, so any arithmetic like `paidAmount + outstandingAmount` or `total - paid` will silently do string concatenation (e.g. \"10\" + \"5\" = \"105\") and JSON serialization will yield strings. Add a value transformer (e.g. a shared ColumnNumericTransformer with to/from: Number) to all five decimal columns, or declare them as `string` if kept raw. Note the same pattern exists elsewhere in the codebase, so a shared transformer is advisable.","suggestion_code":" @Column({ name: 'shared_amount', type: 'decimal', precision: 10, scale: 2, default: 0, transformer: numericTransformer })\n sharedAmount: number;","existing_code":" @Column({ name: 'shared_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })\n sharedAmount: number;"}
{"path":"apps/server/src/entities/bill.entity.ts","start_line":36,"end_line":37,"category":"maintainability","severity":"low","content":"The union type `'batch' | 'student_utility'` is compile-time only; the DB column is a plain varchar with no constraint, so any arbitrary string can be persisted (and TypeORM won't validate it on insert/update). Enforce it at the schema level with the `enum` option (works with MySQL) so invalid values are rejected by the DB.","suggestion_code":" @Column({ type: 'varchar', length: 30, default: 'batch', enum: ['batch', 'student_utility'] })\n source: 'batch' | 'student_utility';","existing_code":" @Column({ type: 'varchar', length: 30, default: 'batch' })\n source: 'batch' | 'student_utility';"}
{"path":"apps/server/src/entities/class-teacher.entity.ts","start_line":40,"end_line":41,"category":"maintainability","severity":"medium","content":"`roleType` is declared as plain `string` even though the `TeacherRoleType` enum is defined in this same file and used throughout the codebase (classes.controller, DTOs, attendance-query.service). This defeats compile-time type safety — arbitrary strings can be assigned and persisted without TS complaining. Additionally, the column decorator does not enforce the enum at the DB level (`varchar(30)` accepts any string ≤30 chars), so invalid roles like `'headteacher'` would silently be stored. Use the enum for the property type and, ideally, `type: 'enum', enum: TeacherRoleType` in the column decorator to validate at the database layer.","suggestion_code":" @Column({ name: 'role_type', type: 'enum', enum: TeacherRoleType, default: TeacherRoleType.SUBJECT_TEACHER })\n roleType: TeacherRoleType;","existing_code":" @Column({ name: 'role_type', length: 30 })\n roleType: string;"}
{"path":"apps/server/src/entities/class-teacher.entity.ts","start_line":43,"end_line":44,"category":"bug","severity":"medium","content":"`subject` is declared as non-nullable `string` while the column is `nullable: true`. TypeORM will return `null` at runtime whenever a non-subject teacher has no subject, but TypeScript still treats it as `string`, hiding potential null dereferences at call sites (e.g., `classes.service.ts` forwards `t.subject` directly into DTOs). Declare the type as `string | null` so callers are forced to handle the null case.","suggestion_code":" @Column({ name: 'subject', length: 50, nullable: true })\n subject: string | null;","existing_code":" @Column({ name: 'subject', length: 50, nullable: true })\n subject: string;"}
{"path":"apps/server/src/entities/classroom.entity.ts","start_line":29,"end_line":30,"category":"maintainability","severity":"medium","content":"The union type is redundant: `ClassroomStatus` is a string enum whose members are exactly `'available' | 'maintenance' | 'archived'`, so the extra literal union adds nothing and makes the type harder to maintain. Additionally, since a status enum is already defined, consider using `type: 'enum', enum: ClassroomStatus` instead of `type: 'varchar'` so the DB enforces valid values (currently any arbitrary string can be stored at the DB level, bypassing TS type checks).","suggestion_code":"@Column({ type: 'enum', enum: ClassroomStatus, default: ClassroomStatus.AVAILABLE })\n status: ClassroomStatus;","existing_code":"@Column({ type: 'varchar', length: 20, default: ClassroomStatus.AVAILABLE })\n status: ClassroomStatus | 'available' | 'maintenance' | 'archived';"}
{"path":"apps/server/src/entities/classroom.entity.ts","start_line":26,"end_line":27,"category":"maintainability","severity":"medium","content":"Hardcoded business string `'大'` as the default for room type. This is a business value (大/次大/小) and should be defined as a constant or enum (e.g., `enum RoomType { LARGE = '大', ... }`) to avoid typos and keep business semantics in one place. If the value set ever changes, callers relying on the raw string will silently drift.","suggestion_code":"@Column({ name: 'room_type', type: 'varchar', length: 20, default: RoomType.LARGE })\n roomType: RoomType; // 大 / 次大 / 小","existing_code":"@Column({ name: 'room_type', type: 'varchar', length: 20, default: '大' })\n roomType: string; // 大 / 次大 / 小"}
{"path":"apps/server/src/entities/class.entity.ts","start_line":36,"end_line":37,"category":"maintainability","severity":"medium","content":"The `ClassType` enum is defined in this file but `classType` is typed as a plain `string`, so invalid values can be assigned/persisted without any compile-time check. Use the enum type for type safety.","suggestion_code":" @Column({ name: 'class_type', length: 20 })\n classType: ClassType;","existing_code":" @Column({ name: 'class_type', length: 20 })\n classType: string;"}
{"path":"apps/server/src/entities/class.entity.ts","start_line":45,"end_line":46,"category":"maintainability","severity":"medium","content":"`status` is typed as `string` even though the `ClassStatus` enum is used for its default value. Declaring it as `ClassStatus` would prevent invalid status values from being stored (e.g., typos like 'actve').","suggestion_code":" @Column({ name: 'status', length: 20, default: ClassStatus.ENROLLING })\n status: ClassStatus;","existing_code":" @Column({ name: 'status', length: 20, default: ClassStatus.ENROLLING })\n status: string;"}
{"path":"apps/server/src/entities/class.entity.ts","start_line":57,"end_line":58,"category":"bug","severity":"medium","content":"Defaulting `max_students` to 0 likely means a class created without an explicit capacity would have 0 seats, effectively rejecting all enrollments. If 0 is not a meaningful \"unlimited\" sentinel, this is a bug — remove the default (require an explicit value) or use a sensible non-zero default / nullable column.","suggestion_code":null,"existing_code":" @Column({ name: 'max_students', type: 'integer', default: 0 })\n maxStudents: number;"}
{"path":"apps/server/src/entities/deposit-installment.entity.ts","start_line":19,"end_line":20,"category":"bug","severity":"medium","content":"TypeORM returns `decimal` columns as strings from the database driver (MySQL/Postgres), so at runtime `amount` will be a `string`, contradicting the `number` type annotation. This can cause silent bugs such as string concatenation in arithmetic (`amount + 5` -> `'10.005'`). Either type the property as `string` or add a `ColumnNumericTransformer` that parses the value to a number.","suggestion_code":null,"existing_code":"@Column({ type: 'decimal', precision: 10, scale: 2 })\n amount: number;"}
{"path":"apps/server/src/entities/deposit-installment.entity.ts","start_line":28,"end_line":29,"category":"maintainability","severity":"low","content":"The `status` field is free-form varchar and only a comment documents the allowed values, so invalid values can be silently persisted. Consider constraining it with a TS enum (e.g., `enum: ['pending', 'paid']` for MySQL, or a Postgres enum / check constraint) to enforce data integrity.","suggestion_code":null,"existing_code":"@Column({ type: 'varchar', length: 20, default: 'pending' })\n status: string; // pending | paid"}
{"path":"apps/server/src/entities/deposit-installment.entity.ts","start_line":16,"end_line":17,"category":"performance","severity":"low","content":"TypeORM does not automatically create a database index on a `@ManyToOne` join column. Since `depositId` is the FK used to look up installments for a deposit, consider adding `@Index('idx_deposit_installments_deposit_id')` to the `depositId` column to avoid full table scans (especially on Postgres, where FKs do not auto-index).","suggestion_code":null,"existing_code":"@Column({ name: 'deposit_id' })\n depositId: number;"}
{"path":"apps/server/src/entities/classroom-rental.entity.ts","start_line":33,"end_line":34,"category":"bug","severity":"medium","content":"This column is declared `nullable: true` in the schema but typed as plain `number`. TypeScript will not surface nullability, so callers may read `lessorOrganizationId` (also `lesseeOrganizationId`, `createdBy`, `dailyRate`, `totalAmount` below) and get `null` at runtime, leading to NPEs/undefined errors. Type these as `number | null` to match the nullable columns.","suggestion_code":" @Column({ name: 'lessor_organization_id', nullable: true })\n lessorOrganizationId: number | null;","existing_code":" @Column({ name: 'lessor_organization_id', nullable: true })\n lessorOrganizationId: number;"}
{"path":"apps/server/src/entities/classroom-rental.entity.ts","start_line":60,"end_line":61,"category":"bug","severity":"medium","content":"TypeORM hydrates `decimal` columns as strings by default (no global transformer is configured in this project). So at runtime `dailyRate`/`totalAmount` will be strings like \"1000.00\", not numbers — arithmetic (`+`, comparisons) can silently misbehave (e.g. string concatenation). Either type them as `string | null` or attach a transformer that converts the DB value to `Number`. If `number` is kept, also fix the nullability (see comment above).","suggestion_code":" @Column({ name: 'daily_rate', type: 'decimal', precision: 10, scale: 2, nullable: true })\n dailyRate: number | null;","existing_code":" @Column({ name: 'daily_rate', type: 'decimal', precision: 10, scale: 2, nullable: true })\n dailyRate: number;"}
{"path":"apps/server/src/entities/classroom-rental.entity.ts","start_line":66,"end_line":67,"category":"maintainability","severity":"low","content":"The union `ClassroomRentalStatus | 'active' | 'ended' | 'cancelled'` is redundant: the three string literals are already members of the `ClassroomRentalStatus` enum. If the enum is later changed, this union can silently drift out of sync. Declare the type as just `ClassroomRentalStatus`.","suggestion_code":" @Column({ type: 'varchar', length: 20, default: ClassroomRentalStatus.ACTIVE })\n status: ClassroomRentalStatus;","existing_code":" @Column({ type: 'varchar', length: 20, default: ClassroomRentalStatus.ACTIVE })\n status: ClassroomRentalStatus | 'active' | 'ended' | 'cancelled';"}
{"path":"apps/server/src/entities/deposit.entity.ts","start_line":40,"end_line":41,"category":"bug","severity":"medium","content":"Type mismatch: `deduction_reason` is `nullable: true`, but the property is typed as non-nullable `string`. Same issue applies to `notes` below. Callers will assume these values are always strings and may not null-check, leading to runtime errors. Type them as `string | null`.","suggestion_code":" @Column({ name: 'deduction_reason', type: 'text', nullable: true })\n deductionReason: string | null;","existing_code":" @Column({ name: 'deduction_reason', type: 'text', nullable: true })\n deductionReason: string;"}
{"path":"apps/server/src/entities/deposit.entity.ts","start_line":21,"end_line":22,"category":"bug","severity":"medium","content":"Type mismatch: TypeORM maps `decimal` columns to `string` values with the default MySQL/Postgres drivers (numbers are returned as strings to preserve currency precision). Declaring `amount: number` (likewise `refundAmount`, `deductionAmount`) is therefore incorrect and can cause silent bugs, e.g. `amount + refundAmount` producing string concatenation instead of arithmetic. Either add a `transformer` that parses to `number`, or type these fields as `string`/`string | null`.","suggestion_code":null,"existing_code":" @Column({ type: 'decimal', precision: 10, scale: 2, default: 500 })\n amount: number;"}
{"path":"apps/server/src/entities/deposit.entity.ts","start_line":55,"end_line":56,"category":"performance","severity":"medium","content":"`eager: true` on both `installments` and `student` means every Deposit load automatically issues additional queries to fetch all installments and the student record. In list/report queries this creates N+1 behavior and can pull large, unnecessary data volumes (especially as installment history grows). Consider dropping `eager` and loading relations explicitly per query via `relations`, or keep eager on at most one lightweight relation.","suggestion_code":null,"existing_code":" @OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true })\n installments: DepositInstallment[];"}
{"path":"apps/server/src/entities/deposit.entity.ts","start_line":25,"end_line":26,"category":"maintainability","severity":"low","content":"`status` is a plain string whose domain values ('paid' | 'refunded' | 'depleted') exist only in a code comment. This allows arbitrary/invalid statuses to be persisted and offers no compile-time safety. Define a TS union type or enum (e.g. `type DepositStatus = 'paid' | 'refunded' | 'depleted'`) and use it for the property type; also consider extracting the hardcoded default `500` and `'paid'` into named constants.","suggestion_code":null,"existing_code":" @Column({ type: 'varchar', length: 20, default: 'paid' })\n status: string;"}
{"path":"apps/server/src/entities/deposit.entity.ts","start_line":31,"end_line":32,"category":"maintainability","severity":"low","content":"Redundant/inconsistent design: `refund_date` (a date) and `refunded_at` (a datetime) both track the same refund event, which can easily drift out of sync and forces callers to decide which one is authoritative. Keep a single source of truth (e.g. `refundedAt`) and derive the date from it if needed.","suggestion_code":null,"existing_code":" @Column({ name: 'refund_date', type: 'date', nullable: true })\n refundDate: string | null;"}
{"path":"apps/server/src/entities/deposit.entity.ts","start_line":61,"end_line":62,"category":"bug","severity":"low","content":"No `onDelete` strategy is specified on the `student` relation. With the default DB behavior (NO ACTION/RESTRICT), deleting a Student who has deposits will fail at the database level, and there is no explicit handling for orphaned deposits. Define an explicit `onDelete` policy (e.g. 'CASCADE'/'SET NULL'/'RESTRICT') in `@JoinColumn` or `@ManyToOne` that matches the intended business rules.","suggestion_code":null,"existing_code":" @ManyToOne(() => Student, { eager: true })\n @JoinColumn({ name: 'student_id' })"}
{"path":"apps/server/src/entities/expense-type.entity.ts","start_line":14,"end_line":14,"category":"maintainability","severity":"low","content":"Hardcoded business string 'room' is used as the default for the `category` column. Per the review rules, business-related hardcoded strings should be avoided — if `room` is a business category value it should be defined as a shared constant or enum (e.g., `export const EXPENSE_CATEGORY = { ROOM: 'room', ... } as const`) so it can be reused and validated consistently across the codebase.","suggestion_code":null,"existing_code":"@Column({ length: 20, default: 'room' })"}
{"path":"apps/server/src/entities/jinshuju-match-rule.entity.ts","start_line":20,"end_line":22,"category":"bug","severity":"medium","content":"`JSON.stringify` returns `string | undefined`; if `mappings` is ever `undefined` (e.g., the entity is constructed without it), this returns `undefined` rather than a string, and the value will be written as NULL into a non-nullable `text` column, which can cause save failures or silently corrupt data. Recommend normalizing the input first: `return JSON.stringify(value ?? {});`","suggestion_code":" to(value: JinshujuFieldMapping): string {\n return JSON.stringify(value ?? {});\n },","existing_code":" to(value: JinshujuFieldMapping): string {\n return JSON.stringify(value);\n },"}
{"path":"apps/server/src/entities/jinshuju-match-rule.entity.ts","start_line":23,"end_line":26,"category":"bug","severity":"medium","content":"`JSON.parse` here can throw a SyntaxError if the DB row contains invalid/corrupted JSON (manual edits, partial writes, or data migration issues), which would break every entity read for the whole table. Additionally, if the stored string is `\"null\"`, `JSON.parse` returns `null` (not an object) and the later cast masks it, so downstream code accessing `rule.mappings.name` would hit a null pointer error. Consider wrapping the parse in try/catch and validating the result, e.g. fall back to `{}` on failure or when the parsed value is not an object.","suggestion_code":" from(value: string | null): JinshujuFieldMapping {\n if (!value) return {};\n try {\n const parsed = JSON.parse(value);\n return (parsed && typeof parsed === 'object' && !Array.isArray(parsed))\n ? (parsed as JinshujuFieldMapping)\n : {};\n } catch {\n return {};\n }\n },","existing_code":" from(value: string | null): JinshujuFieldMapping {\n if (!value) return {};\n return JSON.parse(value) as JinshujuFieldMapping;\n },"}
{"path":"apps/server/src/entities/financial-operation.entity.ts","start_line":6,"end_line":7,"category":"maintainability","severity":"medium","content":"The unique constraint is declared twice for `operationId`: once at the entity level via `@Index(['operationId'], { unique: true })` and again via `unique: true` on the column. TypeORM will generate two separate unique indexes/constraints on the same column, which is redundant (wasted storage on index writes) and causes unnecessary schema/migration churn. Keep only one — recommend removing the entity-level `@Index` and keeping `unique: true` on the column (or vice versa).","suggestion_code":"@Index(['operationId'], { unique: true })\nexport class FinancialOperation {","existing_code":"@Index(['operationId'], { unique: true })\nexport class FinancialOperation {"}
{"path":"apps/server/src/entities/financial-operation.entity.ts","start_line":17,"end_line":18,"category":"maintainability","severity":"medium","content":"`status` is typed as the TS union `FinancialOperationStatus` but the DB column is a plain `varchar`, so the union is only enforced at compile time. Invalid status values (e.g. 'pending') can be silently persisted. Use the TypeORM `enum` column type (e.g. `type: 'enum', enum: ['running', 'completed', 'failed']`) to enforce data integrity at the database level, or at least validate before persisting.","suggestion_code":"@Column({ type: 'enum', enum: ['running', 'completed', 'failed'], default: 'running' })\n status: FinancialOperationStatus;","existing_code":"@Column({ type: 'varchar', length: 20, default: 'running' })\n status: FinancialOperationStatus;"}
{"path":"apps/server/src/entities/financial-operation.entity.ts","start_line":20,"end_line":21,"category":"maintainability","severity":"low","content":"`resultJson` stores JSON payloads as a plain `text` column, which forfeits DB-level JSON validation and the ability to query/navigate the JSON structure. If the backing database supports it (Postgres `jsonb`/`json`, MySQL `json`), prefer the native JSON column type and type the property accordingly (e.g. `Record<string, unknown> | null`) instead of `string | null`.","suggestion_code":"@Column({ name: 'result_json', type: 'jsonb', nullable: true })\n resultJson: Record<string, unknown> | null;","existing_code":"@Column({ name: 'result_json', type: 'text', nullable: true })\n resultJson: string | null;"}
{"path":"apps/server/src/entities/ding-leave-raw.entity.ts","start_line":56,"end_line":57,"category":"bug","severity":"high","content":"Type mismatch: the column is `nullable: true` and the relation uses `onDelete: 'SET NULL'`, so `matchedStudentId` will legitimately be `null` for unmatched records (confirmed by usage such as `raw.matchedStudentId == null` in attendance-lesson.service.ts). Declaring it as `number` lets TypeScript assume it is never null, defeating null-safety checks and risking NPEs/undefined behavior when accessing it. Declare it as `number | null`.","suggestion_code":" @Column({ name: 'matched_student_id', type: 'integer', nullable: true })\n matchedStudentId: number | null;","existing_code":" @Column({ name: 'matched_student_id', type: 'integer', nullable: true })\n matchedStudentId: number;"}
{"path":"apps/server/src/entities/ding-leave-raw.entity.ts","start_line":59,"end_line":61,"category":"bug","severity":"high","content":"Since the FK column can be null (`onDelete: 'SET NULL'`, `nullable: true`), the relation property should be typed as `Student | null`. With `matchedStudent: Student`, TS assumes the object is always present and won't flag accesses like `record.matchedStudent.name` without a null check, which can cause runtime errors for unmatched records.","suggestion_code":" @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true })\n @JoinColumn({ name: 'matched_student_id' })\n matchedStudent: Student | null;","existing_code":" @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true })\n @JoinColumn({ name: 'matched_student_id' })\n matchedStudent: Student;"}
{"path":"apps/server/src/entities/ding-leave-raw.entity.ts","start_line":13,"end_line":15,"category":"performance","severity":"low","content":"`workDate` and `matchStatus` are defined as two separate single-column indexes. Typical queries in this domain filter on both together (e.g., finding unmatched records for a given date during the matching process), where two separate indexes cannot be combined efficiently. Consider a composite index `@Index(['workDate', 'matchStatus'])` (and drop the standalone ones if not needed elsewhere) to speed up those lookups.","suggestion_code":"@Entity('ding_leave_raw')\n@Index(['workDate', 'matchStatus'])","existing_code":"@Entity('ding_leave_raw')\n@Index(['workDate'])\n@Index(['matchStatus'])"}
{"path":"apps/server/src/entities/ding-leave-raw.entity.ts","start_line":53,"end_line":54,"category":"maintainability","severity":"low","content":"`matchStatus` is a business status field stored as a free-form string with default 'unmatched'. Magic strings like 'unmatched'/'matched' are compared across services and can silently drift due to typos. Consider a union type (`'unmatched' | 'matched' | ...`) or a TS enum to keep values centralized and type-safe.","suggestion_code":null,"existing_code":" @Column({ name: 'match_status', length: 20, default: 'unmatched' })\n matchStatus: string;"}
{"path":"apps/server/src/entities/exam-score.entity.ts","start_line":33,"end_line":34,"category":"bug","severity":"medium","content":"Type/nullability mismatch: the DB column `enrollment_id` is defined as `nullable: true`, but the property is typed as non-nullable `number`. Since the relation to StudentEnrollment is optional, this value can legitimately be null, and callers may dereference it assuming it always exists, causing runtime null-pointer errors. Change the type to `number | null` to match the schema.","suggestion_code":" @Column({ name: 'enrollment_id', type: 'integer', nullable: true })\n enrollmentId: number | null;","existing_code":" @Column({ name: 'enrollment_id', type: 'integer', nullable: true })\n enrollmentId: number;"}
{"path":"apps/server/src/entities/exam-score.entity.ts","start_line":40,"end_line":47,"category":"bug","severity":"medium","content":"These three columns are declared `nullable: true` in the schema, but the TypeScript properties are typed as non-nullable `string`. Reading an exam score row with no exam type/name/subject will yield `null` at runtime, contradicting the declared types and risking null-pointer bugs in consumers. Type them as `string | null` (or drop `nullable: true` if the values are truly always required).","suggestion_code":" @Column({ name: 'exam_type', length: 50, nullable: true })\n examType: string | null;\n\n @Column({ name: 'exam_name', length: 100, nullable: true })\n examName: string | null;\n\n @Column({ length: 50, nullable: true })\n subject: string | null;","existing_code":" @Column({ name: 'exam_type', length: 50, nullable: true })\n examType: string;\n\n @Column({ name: 'exam_name', length: 100, nullable: true })\n examName: string;\n\n @Column({ length: 50, nullable: true })\n subject: string;"}
{"path":"apps/server/src/entities/exam-score.entity.ts","start_line":36,"end_line":38,"category":"bug","severity":"medium","content":"The relation is declared `nullable: true` (matching the optional `enrollment_id` column), but the property type is non-nullable `StudentEnrollment`. When an exam score has no enrollment, TypeORM will load `null` here, contradicting the declared type. Use `StudentEnrollment | null` for type safety.","suggestion_code":" @ManyToOne(() => StudentEnrollment, { nullable: true })\n @JoinColumn({ name: 'enrollment_id' })\n enrollment: StudentEnrollment | null;","existing_code":" @ManyToOne(() => StudentEnrollment, { nullable: true })\n @JoinColumn({ name: 'enrollment_id' })\n enrollment: StudentEnrollment;"}
{"path":"apps/server/src/entities/exam-score.entity.ts","start_line":29,"end_line":31,"category":"performance","severity":"low","content":"`eager: true` on this ManyToOne means the Student row is always joined and loaded on every ExamScore query (including list queries), even when the student data is not needed — this can noticeably degrade performance on a potentially large exam_scores table and enlarge response payloads. Consider removing `eager: true` and explicitly using `relations: { student: true }` in queries where the student is actually required.","suggestion_code":null,"existing_code":" @ManyToOne(() => Student, { eager: true })\n @JoinColumn({ name: 'student_id' })\n student: Student;"}
{"path":"apps/server/src/entities/ding-attendance-raw.entity.ts","start_line":32,"end_line":33,"category":"bug","severity":"medium","content":"Nullable DB columns are declared with non-nullable TypeScript types: `checkInTime`/`checkOutTime` (`Date`), `locationResult` (`string`), `rawData` (`string`) and `matchedStudentId` (`number`) are all `nullable: true` in the schema but typed without `| null`. This is inconsistent with `punchSource`/`punchDeviceName`/`punchDeviceId` which correctly use `string | null`, and can cause runtime null-pointer errors when business code assumes these values are always present. Type them as `Date | null`, `string | null`, `number | null` to match the schema.","suggestion_code":null,"existing_code":" @Column({ name: 'check_in_time', type: 'datetime', nullable: true })\n checkInTime: Date;"}
{"path":"apps/server/src/entities/ding-attendance-raw.entity.ts","start_line":15,"end_line":15,"category":"performance","severity":"low","content":"`matchStatus` is a low-cardinality column (few distinct values such as 'unmatched'/'matched'), so a standalone index on it is unlikely to be selected by the query planner and only adds write overhead on every insert/update. If queries filter by status together with attendance date or student, prefer a composite index (e.g. `@Index(['attendanceDate', 'matchStatus'])`) and drop this standalone index; otherwise remove it entirely.","suggestion_code":null,"existing_code":"@Index(['matchStatus'])"}
{"path":"apps/server/src/entities/learning-record.entity.ts","start_line":17,"end_line":18,"category":"bug","severity":"high","content":"`student_id` is mapped twice: once by the raw `@Column` (`studentId`) and once by the `@ManyToOne` + `@JoinColumn` relation (`student`). When saving a LearningRecord, TypeORM will include the `student_id` column for both properties, which can produce an SQL error (\"column student_id specified more than once\") or cause the relation FK to be written unexpectedly. Keep only the relation and access the FK via `record.student.id`, or if a raw accessor is genuinely needed, declare it as `@Column({ name: 'student_id', insert: false, update: false })` and let the relation own writes.","suggestion_code":null,"existing_code":"@Column({ name: 'student_id', type: 'integer' })\nstudentId: number;"}
{"path":"apps/server/src/entities/learning-record.entity.ts","start_line":20,"end_line":20,"category":"performance","severity":"medium","content":"`eager: true` forces every LearningRecord query to JOIN and load the full Student row even when only `studentId` is needed, adding avoidable query cost (and it can be especially wasteful in list queries). Prefer lazy loading with an explicit `relations: ['student']` (or QueryBuilder) at the call sites that actually need the student. Additionally, the relation lacks an `onDelete` behavior — deleting a Student will leave orphaned `student_id` values or fail the FK constraint; specify `onDelete: 'CASCADE'` or `'RESTRICT'` to define the intended semantics.","suggestion_code":null,"existing_code":"@ManyToOne(() => Student, { eager: true })"}
{"path":"apps/server/src/entities/learning-record.entity.ts","start_line":24,"end_line":25,"category":"bug","severity":"low","content":"`record_date` is typed as `string` while `createdAt`/`updatedAt` are typed as `Date`. TypeORM's `date` column type is returned as a `Date` object on some drivers (e.g., PostgreSQL), so the declared type is inaccurate and can lead to incorrect runtime handling. Use `Date` for consistency, or keep `string` only if the driver contract guarantees a string.","suggestion_code":null,"existing_code":"@Column({ name: 'record_date', type: 'date', nullable: true })\nrecordDate: string;"}
{"path":"apps/server/src/entities/learning-record.entity.ts","start_line":24,"end_line":25,"category":"performance","severity":"low","content":"No indexes are declared on the columns most likely used to filter records (`student_id`, `record_date`). If queries commonly fetch records by student or by date range, add `@Index()` to these columns (or a composite index on `(student_id, record_date)`) to avoid full-table scans as the table grows.","suggestion_code":null,"existing_code":"@Column({ name: 'record_date', type: 'date', nullable: true })\nrecordDate: string;"}
{"path":"apps/server/src/entities/notification.entity.ts","start_line":0,"end_line":0,"category":"bug","severity":"medium","content":"Columns declared as `nullable: true` are typed as non-nullable (`string`, `Date`). Under TypeScript `strictNullChecks` this is a type mismatch, and at runtime `content`, `link`, and `readAt` can actually be `null`. Declare them as `string | null` / `Date | null` (and handle null on read) so the entity reflects the real database schema.","suggestion_code":" @Column({ name: 'content', type: 'text', nullable: true })\n content: string | null;\n\n @Column({ name: 'link', length: 500, nullable: true })\n link: string | null;\n\n @Column({ name: 'read_at', type: 'datetime', nullable: true })\n readAt: Date | null;","existing_code":" @Column({ name: 'content', type: 'text', nullable: true })\n content: string;\n\n @Column({ name: 'link', length: 500, nullable: true })\n link: string;\n\n @Column({ name: 'read_at', type: 'datetime', nullable: true })\n readAt: Date;"}
{"path":"apps/server/src/entities/notification.entity.ts","start_line":35,"end_line":36,"category":"maintainability","severity":"medium","content":"`NotificationType` enum is defined above but never used here — the `type` column is typed as plain `string`, so any arbitrary string can be persisted and consumers lose the enum's type safety. Use the enum as the property type (and optionally `type: 'enum'` / `enum: NotificationType` in the column decorator) to enforce valid values.","suggestion_code":" @Column({ name: 'type', length: 30 })\n type: NotificationType;","existing_code":" @Column({ name: 'type', length: 30 })\n type: string;"}
{"path":"apps/server/src/entities/notification.entity.ts","start_line":28,"end_line":29,"category":"performance","severity":"medium","content":"No index is defined on `recipient_id` or `is_read`. Notifications are typically queried by recipient and filtered by read/unread status (e.g., \"unread notifications for user X\"), so without an index these lookups will scan the table. Add indexes (at minimum on `recipient_id`, ideally a composite index on `(recipient_id, is_read)`) to keep these queries efficient as the table grows.","suggestion_code":" @Index()\n @Column({ name: 'recipient_id', type: 'integer' })\n recipientId: number;","existing_code":" @Column({ name: 'recipient_id', type: 'integer' })\n recipientId: number;"}
{"path":"apps/server/src/entities/exam.entity.ts","start_line":31,"end_line":36,"category":"bug","severity":"medium","content":"The `class_id` column is declared twice (via `@Column` and via `@JoinColumn`), and `@ManyToOne` is given no options. TypeORM defaults ManyToOne join columns to `nullable: true`, which conflicts with the non-nullable entity types (`classId: number`, `class: Class`) and can produce a nullable FK in the schema — leading to runtime NPEs (e.g. `exam.class.name`) if `class_id` is ever NULL. Additionally, no `onDelete` is configured, so deleting a `Class` that has exams will fail with a foreign-key constraint error (or leave orphaned rows depending on the DB). Declare the relation explicitly and keep a single source of truth for the column.","suggestion_code":" @Column({ name: 'class_id', type: 'integer' })\n classId: number;\n\n @ManyToOne(() => Class, { nullable: false, onDelete: 'RESTRICT' })\n @JoinColumn({ name: 'class_id' })\n class: Class;","existing_code":" @Column({ name: 'class_id', type: 'integer' })\n classId: number;\n\n @ManyToOne(() => Class)\n @JoinColumn({ name: 'class_id' })\n class: Class;"}
{"path":"apps/server/src/entities/exam.entity.ts","start_line":36,"end_line":36,"category":"maintainability","severity":"low","content":"`class` is a JavaScript reserved word. It is legal as an object property name, but it breaks common destructuring patterns (`const { class } = exam` is a SyntaxError) and can confuse tooling/serialization layers. Consider renaming the property (e.g. `classEntity` / `klass`) while keeping the underlying `class_id` column name unchanged.","suggestion_code":" classEntity: Class;","existing_code":" class: Class;"}
{"path":"apps/server/src/entities/occupancy.entity.ts","start_line":29,"end_line":30,"category":"bug","severity":"medium","content":"The column is defined as `nullable: true`, but the TypeScript property is declared as a non-nullable `string`. TypeORM will return `null` for rows without a check-out, so downstream code can dereference `null` without any compile-time warning. Apply the same fix to the other nullable scalar fields: `billingEndDate`, `checkOutReason`, `notes`, `bedId`, `lockerId`, `responsibleOrganizationId`.","suggestion_code":" @Column({ name: 'check_out_date', type: 'date', nullable: true })\n checkOutDate: string | null;","existing_code":" @Column({ name: 'check_out_date', type: 'date', nullable: true })\n checkOutDate: string;"}
{"path":"apps/server/src/entities/occupancy.entity.ts","start_line":47,"end_line":48,"category":"bug","severity":"medium","content":"`bed_id` is `nullable: true` but `bedId` is typed as plain `number`, so `null` values returned by the database won't be reflected in the type. Also `date`-typed columns are declared as `string`, while TypeORM (especially on PostgreSQL) typically returns `Date` objects — the type should match the actual runtime value.","suggestion_code":" @Column({ name: 'bed_id', type: 'integer', nullable: true })\n bedId: number | null;","existing_code":" @Column({ name: 'bed_id', type: 'integer', nullable: true })\n bedId: number;"}
{"path":"apps/server/src/entities/occupancy.entity.ts","start_line":50,"end_line":52,"category":"maintainability","severity":"medium","content":"This relation is nullable (`{ nullable: true }`), but the property is typed as the non-nullable `Bed`. The same issue applies to `locker` and `responsibleOrganization`. Additionally, `bed_id` is already declared as a standalone `@Column` above; having both a `@Column` and a `@JoinColumn` for the same DB column is redundant and can cause schema-sync/migration conflicts in TypeORM. Prefer keeping only the relation and its `@JoinColumn` (optionally with `{ select: false }` on the FK if a scalar accessor is required).","suggestion_code":" @ManyToOne(() => Bed, { nullable: true })\n @JoinColumn({ name: 'bed_id' })\n bed: Bed | null;","existing_code":" @ManyToOne(() => Bed, { nullable: true })\n @JoinColumn({ name: 'bed_id' })\n bed: Bed;"}
{"path":"apps/server/src/entities/occupancy.entity.ts","start_line":20,"end_line":21,"category":"performance","severity":"medium","content":"`student_id` and `room_id` are frequently used in joins and queries (e.g., filtering occupancies by student or room), but no index is defined. The same applies to `bed_id`, `locker_id`, and `responsible_organization_id`. Without indexes, TypeORM will perform sequential scans on the `occupancies` table, which degrades as data grows. Consider adding `@Index()` on these FK columns (and possibly `status`/`check_in_date` for active-occupancy lookups).","suggestion_code":" @Index()\n @Column({ name: 'student_id' })\n studentId: number;","existing_code":" @Column({ name: 'student_id' })\n studentId: number;"}
{"path":"apps/server/src/entities/occupancy.entity.ts","start_line":61,"end_line":62,"category":"maintainability","severity":"low","content":"`stayType` is declared as a generic `string` with a DB default of `'short'`, while `status` uses a proper union type. If the allowed values are a fixed set (e.g., `'short' | 'long'`), typing the property as a union improves compile-time safety and prevents invalid values being persisted. Also note `length: 10` will reject any future value longer than 10 characters at the DB level.","suggestion_code":" @Column({ name: 'stay_type', length: 10, default: 'short' })\n stayType: 'short' | 'long';","existing_code":" @Column({ name: 'stay_type', length: 10, default: 'short' })\n stayType: string;"}
{"path":"apps/server/src/entities/occupancy.entity.ts","start_line":44,"end_line":45,"category":"maintainability","severity":"low","content":"`status` is typed as `'active' | 'archived'` but the column only has a DB default of `'active'` with no application-level validation. Since `status` is business-critical (drives filtering of active/archived occupancies), consider adding an application-level check or enum validation (e.g., TypeORM `enum` type) to prevent invalid values from being persisted, and to guarantee the union type actually holds at runtime.","suggestion_code":" @Column({ type: 'enum', enum: ['active', 'archived'], default: 'active' })\n status: 'active' | 'archived';","existing_code":" @Column({ type: 'varchar', length: 20, default: 'active' })\n status: 'active' | 'archived';"}
{"path":"apps/server/src/entities/locker.entity.ts","start_line":30,"end_line":31,"category":"maintainability","severity":"medium","content":"The `status` field is a plain `string` with a hardcoded magic-string default `'available'`. Locker status values ('available'/'occupied'/'maintenance') are business domain values used across the app (e.g., `rooms/dto/locker.dto.ts` validates with `@IsEnum(['available', 'occupied', 'maintenance'])`, and services check `locker.status !== 'available'`). With a bare varchar column and no constraint, invalid values (e.g., a typo like 'availble') can silently persist, and there is no type safety at the entity level. Follow the existing `ClassroomStatus` enum pattern in the codebase: define `export enum LockerStatus { AVAILABLE = 'available', OCCUPIED = 'occupied', MAINTENANCE = 'maintenance' }`, type the column with it, and use `LockerStatus.AVAILABLE` as the default (optionally `type: 'enum'` for DB-level enforcement).","suggestion_code":"@Column({ type: 'enum', enum: LockerStatus, default: LockerStatus.AVAILABLE })\n status: LockerStatus;","existing_code":"@Column({ type: 'varchar', length: 20, default: 'available' })\n status: string;"}
{"path":"apps/server/src/entities/operation-log.entity.ts","start_line":8,"end_line":9,"category":"performance","severity":"low","content":"Operation log tables accumulate large volumes of rows and are typically queried/filtered by userId, targetId, and createdAt. Without indexes on these columns, such queries will degrade to full table scans as the table grows. Consider adding @Index() to userId, targetId, and createdAt (or a composite index covering the most common query pattern).","suggestion_code":null,"existing_code":" @Column({ name: 'user_id', nullable: true })\n userId: number;"}
{"path":"apps/server/src/entities/organization.entity.ts","start_line":26,"end_line":27,"category":"bug","severity":"medium","content":"Columns `contactName`, `phone`, `color`, and `notes` are defined as `nullable: true` in the DB schema but typed as non-nullable `string`. TypeORM does not automatically widen property types to `string | null`, so under `strictNullChecks` the compiler will not warn when these fields are accessed, and dereferencing a NULL DB value (e.g., `org.contactName.trim()`) will throw a runtime error. Declare them as `string | null` to reflect the actual schema and force callers to perform null checks.","suggestion_code":" @Column({ name: 'contact_name', length: 50, nullable: true })\n contactName: string | null;","existing_code":" @Column({ name: 'contact_name', length: 50, nullable: true })\n contactName: string;"}
{"path":"apps/server/src/entities/organization.entity.ts","start_line":38,"end_line":39,"category":"maintainability","severity":"low","content":"The default `'active'` is a business magic string embedded in the decorator, and the union type `'active' | 'archived'` is only a compile-time hint — the DB column has no CHECK constraint and no shared constant ties the type to the default, so a typo in one place silently diverges from the other (and invalid values can be persisted). Extract the status values into a shared enum/constant and reference it both in the default and the type; consider adding a CHECK constraint at the DB level for integrity.","suggestion_code":" @Column({ type: 'varchar', length: 20, default: OrganizationStatus.ACTIVE })\n status: OrganizationStatus;","existing_code":" @Column({ type: 'varchar', length: 20, default: 'active' })\n status: 'active' | 'archived';"}
{"path":"apps/server/src/entities/permission.entity.ts","start_line":14,"end_line":15,"category":"maintainability","severity":"medium","content":"`group` is a reserved keyword in SQL (MySQL 8.0 lists GROUP as a reserved word). Every query that touches this column must escape it — note the initial migration already has to write `` `group` `` with backticks, and any future raw SQL / query-builder code that forgets to escape will fail with a syntax error. This makes the entity fragile and error-prone. Recommend mapping to a non-reserved DB column name (e.g., `name: 'permission_group'`) while keeping the entity property, and adding a column-rename migration.","suggestion_code":"@Column({ type: 'varchar', length: 30, name: 'permission_group' })\ngroup: string;","existing_code":"@Column({ type: 'varchar', length: 30 })\ngroup: string;"}
{"path":"apps/server/src/entities/room-expense.entity.ts","start_line":13,"end_line":14,"category":"maintainability","severity":"low","content":"`importKey` gets a unique constraint from the column-level `unique: true` AND a separate unique index from the class-level `@Index(['importKey'], { unique: true })`. TypeORM will emit two identical unique indexes on the same column, which adds redundant DDL and index-maintenance overhead on every insert/update. Keep only one of them (e.g., remove the column-level `unique: true` and keep the class-level index, or vice versa).","suggestion_code":"@Index(['importKey'])\nexport class RoomExpense {","existing_code":"@Index(['importKey'], { unique: true })\nexport class RoomExpense {"}
{"path":"apps/server/src/entities/room-expense.entity.ts","start_line":18,"end_line":19,"category":"performance","severity":"low","content":"The most common query pattern for this entity is fetching all expenses of a room (`WHERE room_id = ?`). The `room_id` column is only covered by the FK of the `ManyToOne` relation (and FKs don't always create an index, e.g., in Postgres/SQLite). Add an explicit index on `room_id` to avoid full-table scans as the table grows.","suggestion_code":" @Column({ name: 'room_id' })\n @Index()\n roomId: number;","existing_code":" @Column({ name: 'room_id' })\n roomId: number;"}
{"path":"apps/server/src/entities/room-expense.entity.ts","start_line":24,"end_line":25,"category":"bug","severity":"medium","content":"`amount` is declared as `number`, but with `type: 'decimal'` most database drivers (notably MySQL) return the value as a string, and TypeORM does not transform it back to a number by default. Arithmetic on this property (`expense.amount + 1`) can silently concatenate strings at runtime, and JS `number` cannot precisely represent fixed-point decimals anyway. Add a `transformer` to convert between string/number on read/write, or document and consistently handle the string type.","suggestion_code":null,"existing_code":" @Column({ type: 'decimal', precision: 10, scale: 2 })\n amount: number;"}
{"path":"apps/server/src/entities/result-archive.entity.ts","start_line":24,"end_line":25,"category":"bug","severity":"medium","content":"`decimal(5,2)` columns are typed as `number`, but both MySQL and PostgreSQL drivers return DECIMAL/NUMERIC values as strings at runtime (e.g. `\"87.50\"`). Any arithmetic or comparisons on `cultureFinalScore`/`professionalFinalScore` will get string coercion behavior instead of numeric. Add a transformer that converts to `parseFloat` on read (and back on write), or declare the property as `string` to match the driver.","suggestion_code":" @Column({\n name: 'culture_final_score',\n type: 'decimal',\n precision: 5,\n scale: 2,\n nullable: true,\n transformer: {\n to: (value: number | null) => value,\n from: (value: string | null) => (value === null ? null : parseFloat(value)),\n },\n })\n cultureFinalScore: number;","existing_code":" @Column({ name: 'culture_final_score', type: 'decimal', precision: 5, scale: 2, nullable: true })\n cultureFinalScore: number;"}
{"path":"apps/server/src/entities/result-archive.entity.ts","start_line":20,"end_line":22,"category":"maintainability","severity":"low","content":"`student_id` is declared `unique: true`, so each student can have at most one archive — this is effectively a one-to-one relationship, but it is modeled as `@ManyToOne`. Using `@OneToOne(() => Student, { eager: true })` would express the intent correctly and also allow options like `onDelete: 'CASCADE'` so an archive is removed with its student.","suggestion_code":" @OneToOne(() => Student, { eager: true, onDelete: 'CASCADE' })\n @JoinColumn({ name: 'student_id' })\n student: Student;","existing_code":" @ManyToOne(() => Student, { eager: true })\n @JoinColumn({ name: 'student_id' })\n student: Student;"}
{"path":"apps/server/src/entities/result-archive.entity.ts","start_line":20,"end_line":22,"category":"performance","severity":"low","content":"`eager: true` makes TypeORM load the full `Student` row (with all of its columns) on every query for a `ResultArchive`, including list queries where the student object may not be needed. Consider removing `eager` and loading the relation explicitly (e.g. via the `relations` option in find) only where it is actually used.","suggestion_code":" @ManyToOne(() => Student)\n @JoinColumn({ name: 'student_id' })\n student: Student;","existing_code":" @ManyToOne(() => Student, { eager: true })\n @JoinColumn({ name: 'student_id' })\n student: Student;"}
{"path":"apps/server/src/entities/role.entity.ts","start_line":21,"end_line":22,"category":"maintainability","severity":"medium","content":"`code` is declared both `unique: true` and `nullable: true`. A UNIQUE constraint treats NULLs as distinct, so multiple roles can end up with a NULL `code`, which defeats the purpose of the unique business key. If `code` is meant to be the stable identifier used for RBAC/permission checks (as opposed to the display `name`), it should be `nullable: false`; if it is genuinely optional, the uniqueness requirement and the lookup logic relying on it should be reconsidered.","suggestion_code":" @Column({ type: 'varchar', length: 30, unique: true })\n code: string;","existing_code":" @Column({ type: 'varchar', length: 30, unique: true, nullable: true })\n code: string;"}
{"path":"apps/server/src/entities/role.entity.ts","start_line":39,"end_line":44,"category":"other","severity":"medium","content":"Neither ManyToMany relation defines a cascade/delete strategy. TypeORM's generated junction tables (`role_permissions`, `user_roles`) get FK columns without `ON DELETE CASCADE` by default. While `repository.remove()` cleans up junction rows first, any bulk/raw deletion (e.g. `queryBuilder().delete()`, direct SQL) of a Role will fail with a foreign-key constraint error, and users are not protected from orphaned `user_roles` rows. Consider adding an explicit `onDelete`/cascade policy on the join columns if deletion of roles is expected.","suggestion_code":null,"existing_code":" @ManyToMany(() => Permission)\n @JoinTable({\n name: 'role_permissions',\n joinColumn: { name: 'role_id', referencedColumnName: 'id' },\n inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },\n })"}
{"path":"apps/server/src/entities/role.entity.ts","start_line":30,"end_line":31,"category":"maintainability","severity":"low","content":"The `status` column uses a magic value (`default: 1` = enabled?) with no documented semantics. Since status values drive authorization/visibility logic, define a constants/enum (e.g. `RoleStatus`) with explicit meanings and reference it here and in service code to avoid silent inconsistencies.","suggestion_code":null,"existing_code":" @Column({ type: 'tinyint', default: 1 })\n status: number;"}
{"path":"apps/server/src/entities/room-inspection-detail.entity.ts","start_line":39,"end_line":41,"category":"maintainability","severity":"medium","content":"Inconsistent referential actions: the `bed` relation uses `onDelete: 'SET NULL'` and the entity stores `studentNameSnapshot`/`bedNumberSnapshot` specifically to preserve history after referenced records change/are removed, yet `student` and `occupancy` use `onDelete: 'RESTRICT'`. This means a student or occupancy that has ever been inspected can never be hard-deleted from the database, which blocks record cleanup (e.g., removing a checked-out student's history) and contradicts the snapshot-based design. Consider `SET NULL` on these relations as well, or document that hard-deletion of students/occupancies with inspection history is intentionally forbidden.","suggestion_code":null,"existing_code":" @ManyToOne(() => Student, { onDelete: 'RESTRICT' })\n @JoinColumn({ name: 'student_id' })\n student: Student;"}
{"path":"apps/server/src/entities/room-inspection-detail.entity.ts","start_line":50,"end_line":51,"category":"maintainability","severity":"low","content":"`status` is constrained only by the TypeScript union type (`'present' | 'absent'`) but is persisted as a plain `varchar(20)` with no database-level constraint. Any code path that bypasses TypeScript checking (raw SQL, migrations, future callers) can silently write an invalid status, and the `RoomInspectionStatus` type must be kept in sync manually. Consider using a database `enum` type or a CHECK constraint (e.g., `status IN ('present', 'absent')`) to enforce valid values at the DB layer.","suggestion_code":null,"existing_code":" @Column({ type: 'varchar', length: 20 })\n status: RoomInspectionStatus;"}
{"path":"apps/server/src/entities/room.entity.ts","start_line":31,"end_line":32,"category":"bug","severity":"medium","content":"Type mismatch: TypeORM maps `type: 'decimal'` to a string in MySQL drivers (DECIMAL/NUMERIC columns are returned as strings at runtime, not numbers). Declaring the property as `number` is therefore inaccurate — arithmetic like `monthlyRate * days` will do string concatenation, and JSON serialization will emit \"1500.00\" instead of 1500. This is already visible in the codebase: bills.service.spec.ts has to cast `'800' as unknown as number` to fit this type. Either declare the field as `string`, or use a transformer that converts to/from `number` (e.g. `{ to: (v) => v, from: (v) => parseFloat(v) }`).","suggestion_code":null,"existing_code":"@Column({ name: 'monthly_rate', type: 'decimal', precision: 10, scale: 2, default: 0 })\n monthlyRate: number;"}
{"path":"apps/server/src/entities/room.entity.ts","start_line":22,"end_line":23,"category":"maintainability","severity":"low","content":"Business values ('available', 'short') are hardcoded as bare strings with no constraint at the DB or type level, so any typo or invalid value (e.g. 'avaliable', 'long') silently persists. Consider using a dedicated enum type: `@Column({ type: 'enum', enum: ['available', 'occupied', ...], default: 'available' })` and a matching TS union type for `status`, and similarly for `rental_category` (e.g. 'short' | 'long'). This prevents invalid states and makes the allowed values self-documenting.","suggestion_code":null,"existing_code":"@Column({ type: 'varchar', length: 20, default: 'available' })\n status: string;"}
{"path":"apps/server/src/entities/student-wallet.entity.ts","start_line":20,"end_line":21,"category":"bug","severity":"high","content":"Type mismatch: DECIMAL columns are returned as strings by the MySQL/Postgres drivers, so `balance` will actually be a `string` at runtime even though it's typed `number`. This can cause silent bugs like string concatenation instead of arithmetic (e.g., `balance + amount`). Either declare the property as `string`, or add a transformer that converts the DB value to a number:\n\n```ts\n@Column({\n type: 'decimal',\n precision: 12,\n scale: 2,\n default: 0,\n transformer: {\n to: (v: number) => v,\n from: (v: string) => parseFloat(v),\n },\n})\nbalance: number;\n```","suggestion_code":null,"existing_code":" @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 })\n balance: number;"}
{"path":"apps/server/src/entities/student-wallet.entity.ts","start_line":29,"end_line":31,"category":"bug","severity":"medium","content":"The `student_id` database column is declared twice: once explicitly via `@Column({ name: 'student_id' })` on `studentId`, and once implicitly via `@JoinColumn({ name: 'student_id' })` on the relation. TypeORM will register duplicate column metadata for the same DB column, which can lead to \"Duplicate column\" errors during schema sync/migrations and makes the two mappings drift apart. Prefer keeping only the relation and exposing the FK via `@RelationId`, or keep the explicit column and drop the `@JoinColumn` name duplication (e.g., let the relation own the column).","suggestion_code":null,"existing_code":" @OneToOne(() => Student, { onDelete: 'CASCADE' })\n @JoinColumn({ name: 'student_id' })\n student: Student;"}
{"path":"apps/server/src/entities/student-enrollment.entity.ts","start_line":20,"end_line":20,"category":"performance","severity":"medium","content":"`eager: true` makes TypeORM automatically join and load the full Student record on EVERY StudentEnrollment query, including list/batch queries where the student data may not be needed. If Student has many columns or enrollment lists are large, this causes unnecessary joins, extra data transfer, and slower queries. Prefer explicit loading (`relations: ['student']` in find options or QueryBuilder `leftJoinAndSelect`) only where student data is actually required.","suggestion_code":" @ManyToOne(() => Student)","existing_code":" @ManyToOne(() => Student, { eager: true })"}
{"path":"apps/server/src/entities/student-enrollment.entity.ts","start_line":17,"end_line":17,"category":"performance","severity":"medium","content":"`student_id` is a foreign key that will typically be used to filter enrollments by student. No index is defined on it. While MySQL/InnoDB auto-creates an index for FK columns, PostgreSQL and several other databases do not, so lookups by student will result in full table scans as data grows. Add `index: true` to the column or an `@Index()` on the property.","suggestion_code":" @Column({ name: 'student_id', type: 'integer', index: true })","existing_code":" @Column({ name: 'student_id', type: 'integer' })"}
{"path":"apps/server/src/entities/student-enrollment.entity.ts","start_line":39,"end_line":40,"category":"bug","severity":"low","content":"The `type: 'date'` columns are declared with TypeScript type `string`, but the runtime value returned by the driver depends on the database: MySQL returns date strings, whereas PostgreSQL (node-postgres) returns `Date` objects for DATE columns. If downstream code assumes `startDate`/`endDate` is always a string (e.g., calls `.slice()` or string comparison), it can fail at runtime. Align the declared type with the actual driver return value (e.g., `Date`) or normalize the value on read.","suggestion_code":" @Column({ name: 'start_date', type: 'date', nullable: true })\n startDate: Date;","existing_code":" @Column({ name: 'start_date', type: 'date', nullable: true })\n startDate: string;"}
{"path":"apps/server/src/entities/student-enrollment.entity.ts","start_line":45,"end_line":45,"category":"maintainability","severity":"low","content":"The business state `'active'` is hardcoded as a magic string in the column default. If the set of valid statuses changes or the value needs to be referenced/compared elsewhere (e.g., filtering active enrollments), a magic string invites typos and drift. Consider defining a shared status enum/constant and referencing it here.","suggestion_code":null,"existing_code":" @Column({ length: 20, default: 'active' })"}
{"path":"apps/server/src/entities/personal-expense.entity.ts","start_line":25,"end_line":26,"category":"bug","severity":"medium","content":"DECIMAL columns are returned as strings by the MySQL/PostgreSQL drivers; TypeORM does not auto-cast `decimal` to a JS number. Typing `amount` as `number` is therefore a type mismatch — at runtime it will be a string, so arithmetic (`+`), comparisons, and JSON serialization behave incorrectly (e.g., string concatenation). Type it as `string`, or add a transformer, e.g. `transformer: { to: (v) => v, from: (v) => parseFloat(v) }`.","suggestion_code":null,"existing_code":" @Column({ type: 'decimal', precision: 10, scale: 2 })\n amount: number;"}
{"path":"apps/server/src/entities/personal-expense.entity.ts","start_line":19,"end_line":20,"category":"bug","severity":"medium","content":"`room_id` is declared `nullable: true`, but the property is typed as a non-nullable `number`; the same applies to `recorded_by` (`recordedBy: number`) and `description` (`string`). At runtime these fields can legitimately be `null`, which silently violates the TS contract and can cause null-pointer/type errors downstream. Note `billId` already uses the correct `number | null` — align these nullable columns with `number | null` / `string | null`.","suggestion_code":null,"existing_code":" @Column({ name: 'room_id', nullable: true })\n roomId: number;"}
{"path":"apps/server/src/entities/personal-expense.entity.ts","start_line":16,"end_line":17,"category":"maintainability","severity":"low","content":"The database column `student_id` is mapped twice: once by the explicit `@Column({ name: 'student_id' })` and again by `@JoinColumn({ name: 'student_id' })` on the `student` relation. Depending on the TypeORM version this can raise a duplicate-column metadata error or cause the FK column to be emitted twice in generated SELECT/INSERT statements. Prefer a single source of truth — either keep the explicit `studentId` column and remove `@JoinColumn`, or keep the relation and drop the explicit `@Column` (accessing the FK through `student`).","suggestion_code":null,"existing_code":" @Column({ name: 'student_id' })\n studentId: number;"}
{"path":"apps/server/src/entities/room-inspection.entity.ts","start_line":24,"end_line":25,"category":"bug","severity":"medium","content":"Type mismatch: `inspectionDate` is declared as `string`, but this is a MySQL `date` column and the app does not configure `dateStrings` in its DataSource options (mysql2 defaults to returning JS `Date` objects). At runtime the hydrated property is a `Date`, so when entities are returned via `getByRoomsAndDate()` and JSON-serialized, values come out like `2026-08-08T16:00:00.000Z` (in Asia/Shanghai this shifts the calendar date to the previous day) instead of the `YYYY-MM-DD` string the rest of the code (e.g. `assertToday`, string date comparisons, frontend) expects. Align the property type with the runtime value (declare `Date` and convert explicitly), or add a column `transformer` that normalizes to a plain `YYYY-MM-DD` string.","suggestion_code":null,"existing_code":" @Column({ name: 'inspection_date', type: 'date' })\n inspectionDate: string;"}
{"path":"apps/server/src/entities/room-inspection.entity.ts","start_line":44,"end_line":45,"category":"maintainability","severity":"low","content":"The `'manual' | 'automatic'` union for `source` is enforced only at compile time; the column is a plain `varchar(20)` with no database-level validation, so any invalid value would be silently persisted and would then break code that branches on `source` (e.g. `settleDate` looks up `source: 'manual'`). Consider declaring the column as `type: 'enum'` with the two values (or adding a CHECK constraint) so the DB enforces the invariant.","suggestion_code":null,"existing_code":" @Column({ type: 'varchar', length: 20, default: 'manual' })\n source: RoomInspectionSource;"}
{"path":"apps/server/src/entities/sync-log.entity.ts","start_line":12,"end_line":13,"category":"performance","severity":"medium","content":"This table accumulates one row per sync run and is queried frequently by `SyncService.getLogs`/`getLastSync` with `where: { platform }` plus `order: { createdAt: 'DESC' }`. There is currently no index on `platform` or `created_at` (confirmed in migration `1784520727860-InitialSchema.ts`), so these lookups will degrade into a full table scan + filesort as the log table grows. Consider adding a composite index, e.g. `@Index('IDX_sync_logs_platform_created', ['platform', 'createdAt'])` on the entity (and a matching migration).","suggestion_code":null,"existing_code":"@Column({ type: 'varchar', length: 20 })\n platform: SyncPlatform;"}
{"path":"apps/server/src/entities/user.entity.ts","start_line":26,"end_line":27,"category":"bug","severity":"medium","content":"This column is `nullable: true` in the DB but typed as non-nullable `string` in TypeScript. The same problem affects `lastLoginAt: Date` and `profile: {...}` below. TypeORM will return `null` for these columns, so code that directly accesses them (e.g. `user.lastLoginAt.toISOString()` or `user.profile.subjects`) can throw at runtime. The TS types should reflect nullability: `name: string | null`, `lastLoginAt: Date | null`, `profile: { ... } | null`.","suggestion_code":" @Column({ length: 50, nullable: true })\n name: string | null;","existing_code":" @Column({ length: 50, nullable: true })\n name: string;"}
{"path":"apps/server/src/entities/user.entity.ts","start_line":20,"end_line":21,"category":"security","severity":"medium","content":"`password_hash` is a sensitive field and is included in the default query result set. If a `User` entity is returned directly by a service/controller (or serialized via `class-transformer`/`JSON.stringify`), the password hash can leak to API consumers. Consider adding `select: false` to this column and explicitly selecting it only where needed (e.g. in the authentication flow), or excluding it during serialization.","suggestion_code":" @Column({ name: 'password_hash', length: 255, select: false })\n passwordHash: string;","existing_code":" @Column({ name: 'password_hash', length: 255 })\n passwordHash: string;"}
{"path":"apps/server/src/entities/user.entity.ts","start_line":23,"end_line":24,"category":"maintainability","severity":"low","content":"These commented-out column declarations are dead code. If they document a schema migration, consider recording the change in a migration file or changelog instead of leaving commented-out code in the entity, so the entity definition stays clean and the DB schema is version-controlled.","suggestion_code":null,"existing_code":" // 移除: @Column({ type: 'varchar', length: 20, default: 'operator' })\n // role: string;"}
{"path":"apps/server/src/entities/student.entity.ts","start_line":61,"end_line":66,"category":"maintainability","severity":"medium","content":"The `user_id` column is defined twice: once explicitly via `@Column` and once implicitly generated by `@JoinColumn({ name: 'user_id' })`. Since `synchronize` defaults to true in app.module.ts, having both definitions is risky (can cause \"duplicate column\" errors during schema sync) and leaves two sources of truth for the same column. Also, `unique: true` is redundant — a OneToOne owning side already creates a unique constraint on the join column. Prefer keeping only the relation (and access the FK value via `student.user.id`), or drop the `@JoinColumn` duplicate.","suggestion_code":null,"existing_code":" @Column({ name: 'user_id', type: 'integer', nullable: true, unique: true })\n userId: number;\n\n @OneToOne(() => User, { nullable: true, onDelete: 'SET NULL' })\n @JoinColumn({ name: 'user_id' })\n user: User;"}
{"path":"apps/server/src/entities/student.entity.ts","start_line":68,"end_line":73,"category":"maintainability","severity":"medium","content":"Same issue as `user_id`: the `organization_id` column is declared twice — once via `@Column` and once via `@JoinColumn({ name: 'organization_id' })` on the `@ManyToOne`. With `synchronize: true`, TypeORM can generate conflicting column definitions. Additionally, `organization_id` is a frequently queried FK and has no index (TypeORM does not always auto-index FK columns); consider adding `@Index` if queries filter by organization.","suggestion_code":null,"existing_code":" @Column({ name: 'organization_id', type: 'integer', nullable: true })\n organizationId: number;\n\n @ManyToOne(() => Organization, { nullable: true })\n @JoinColumn({ name: 'organization_id' })\n organization: Organization;"}
{"path":"apps/server/src/entities/student.entity.ts","start_line":31,"end_line":32,"category":"security","severity":"medium","content":"`id_number` stores the national ID (a highly sensitive piece of PII) in plaintext in the database. Anyone with DB access can read it, and a leak exposes irreversible personal data. Consider encrypting it at rest (e.g., application-level AES) or storing only a hashed value if full plaintext is not strictly needed, and restrict read access.","suggestion_code":null,"existing_code":" @Column({ name: 'id_number', length: 30, nullable: true })\n idNumber: string;"}
{"path":"apps/server/src/entities/student-ding-mapping.entity.ts","start_line":18,"end_line":20,"category":"bug","severity":"low","content":"Nullability mismatch: `student_id` is declared via `@Column` without `nullable`, so it is NOT NULL in the DB, but TypeORM relations default to `nullable: true`. As a result the TS type `student: Student` can actually be `undefined` at runtime whenever the relation is not explicitly joined, which can lead to null-pointer errors in consumers (e.g. services doing `mapping.student.xxx`). Align the definitions by declaring `@ManyToOne(() => Student, { nullable: false, onDelete: 'CASCADE' })` or typing the property as `Student | null` and null-checking at call sites.","suggestion_code":" @ManyToOne(() => Student, { nullable: false, onDelete: 'CASCADE' })\n @JoinColumn({ name: 'student_id' })\n student: Student;","existing_code":" @ManyToOne(() => Student, { onDelete: 'CASCADE' })\n @JoinColumn({ name: 'student_id' })\n student: Student;"}
{"path":"apps/server/src/entities/student-ding-mapping.entity.ts","start_line":20,"end_line":20,"category":"maintainability","severity":"low","content":"Cardinality mismatch: `student_id` is declared `unique: true`, so a student can have at most one Ding mapping — this is strictly a 1:1 relationship, not a many-to-one. Using `@ManyToOne` here is misleading about the data model. Express the intent with `@OneToOne(() => Student, { onDelete: 'CASCADE' })` + `@JoinColumn({ name: 'student_id' })`, which also makes the `unique` constraint on the join column redundant/implicit.","suggestion_code":" student: Student;","existing_code":" student: Student;"}
{"path":"apps/server/src/entities/student-ding-mapping.entity.ts","start_line":15,"end_line":16,"category":"maintainability","severity":"medium","content":"The `student_id` column is declared twice: once via `@Column` and once implicitly through `@ManyToOne`/`@JoinColumn` with the same name. TypeORM 0.3.x reuses the existing column, but this leaves two sources of truth for the same DB column (type, nullability, uniqueness), and any drift between them (e.g. changing nullability on one side) can silently produce inconsistent DDL/runtime behavior. Keep a single source of truth — e.g. keep the `@Column` for `unique`, or drop it and enforce uniqueness via `@Index({ unique: true })` on the relation — and add a short comment explaining that this is an enforced 1:1 mapping.","suggestion_code":null,"existing_code":" @Column({ name: 'student_id', type: 'integer', unique: true })\n studentId: number;"}
{"path":"apps/server/src/entities/sync-state.entity.ts","start_line":2,"end_line":2,"category":"maintainability","severity":"low","content":"`SyncPlatform` is a shared domain type that is imported from another entity file (`sync-log.entity.ts`) rather than from a shared types module. This couples `SyncState` to `SyncLog` purely for a type: any rename/refactor of the type in the log entity silently breaks this entity, and the union may drift out of sync with the DB column. Recommend extracting `SyncPlatform` (and the other sync union types) into a dedicated shared types file and importing from there.","suggestion_code":null,"existing_code":"import type { SyncPlatform } from './sync-log.entity';"}
{"path":"apps/server/src/entities/sync-state.entity.ts","start_line":6,"end_line":7,"category":"maintainability","severity":"low","content":"The `platform` column is sized `varchar(20)`, but the longest current `SyncPlatform` value (`'dingtalk_attendance'`) is already 19 characters. This leaves no headroom: adding a new platform with a name longer than 20 chars will cause a DB truncation/insert failure, and this length must be manually kept in sync with `migrations/1784520727860-InitialSchema.ts` and `init.sql`. Consider using a larger length (e.g., 32) or a DB-level CHECK constraint to enforce the allowed values.","suggestion_code":null,"existing_code":"@PrimaryColumn({ type: 'varchar', length: 20 })\n platform: SyncPlatform;"}
{"path":"apps/server/src/entities/student-profile.entity.ts","start_line":17,"end_line":18,"category":"bug","severity":"high","content":"The `student_id` database column is mapped twice: once by the `@Column` property `studentId` and once by the `@JoinColumn({ name: 'student_id' })` of the `student` relation. TypeORM will register two column metadata entries for the same physical column, so an INSERT (e.g. `profileRepo.create({ ...dto, studentId })` in archive.service) can emit the column twice in the SQL (`INSERT INTO student_profiles (student_id, ..., student_id) ...`), causing MySQL error 1110 \"Column 'student_id' specified twice\" or ambiguous mapping at runtime. Keep only the relation (with `@JoinColumn`) and expose the FK id via `@RelationId`, or drop the relation and keep only the `@Column`.","suggestion_code":" @RelationId((profile: StudentProfile) => profile.student)\n studentId: number;","existing_code":" @Column({ name: 'student_id', type: 'integer', unique: true })\n studentId: number;"}
{"path":"apps/server/src/entities/student-profile.entity.ts","start_line":20,"end_line":22,"category":"performance","severity":"low","content":"`eager: true` on this `@ManyToOne` forces a LEFT/INNER JOIN on `students` for every `StudentProfile` query (findOne/find/save), even when only the FK is needed. Given `studentId` is already `unique`, this is effectively a 1:1 relationship — prefer `@OneToOne` without `eager` and load `student` explicitly where it is actually used (e.g. via `relations`/`leftJoinAndSelect`), which avoids the unconditional JOIN overhead and follows the pattern used elsewhere in this codebase (e.g. `Student.user` is not eager).","suggestion_code":" @OneToOne(() => Student)\n @JoinColumn({ name: 'student_id' })\n student: Student;","existing_code":" @ManyToOne(() => Student, { eager: true })\n @JoinColumn({ name: 'student_id' })\n student: Student;"}
{"path":"apps/server/src/entities/wallet-transaction.entity.ts","start_line":21,"end_line":25,"category":"bug","severity":"medium","content":"`DECIMAL(12,2)` columns are returned as strings by the DB driver (MySQL/pg) and TypeORM performs no automatic conversion to number. Declaring `amount`/`balanceAfter` as `number` therefore misrepresents the runtime type: downstream code doing arithmetic (`tx.amount + x`) would get string concatenation, and `toFixed()`/numeric methods would throw. Type them as `string` or add an explicit transformer, e.g. `transformer: { to: (v) => v, from: (v) => Number(v) }`, so the entity type matches what is actually read back from the database.","suggestion_code":null,"existing_code":" @Column({ type: 'decimal', precision: 12, scale: 2 })\n amount: number;\n\n @Column({ name: 'balance_after', type: 'decimal', precision: 12, scale: 2 })\n balanceAfter: number;"}
{"path":"apps/server/src/entities/wallet-transaction.entity.ts","start_line":15,"end_line":16,"category":"bug","severity":"medium","content":"`operation_id` is limited to varchar(64), but `wallets.service.ts` `batchChangeBalance` writes a compound value `${operationId}:${studentId}` into this column. A user-supplied operationId near the 64-char limit plus the `:` and studentId suffix will exceed 64 characters, causing the INSERT to fail with a data-too-long error (the value is not re-validated before being persisted). Increase the column length (e.g., 128) or avoid embedding the studentId into the stored operationId.","suggestion_code":null,"existing_code":" @Column({ name: 'operation_id', type: 'varchar', length: 64, nullable: true })\n operationId: string | null;"}
{"path":"apps/server/src/expense-types/dto/expense-type.dto.ts","start_line":13,"end_line":18,"category":"maintainability","severity":"medium","content":"Category values ['room', 'personal', 'both'] are business constants hardcoded and duplicated verbatim in both CreateExpenseTypeDto and UpdateExpenseTypeDto, so they can silently drift out of sync. Extract a shared constant (e.g., `export const EXPENSE_TYPE_CATEGORIES = ['room', 'personal', 'both'] as const;` plus a derived union type) and use `@IsIn(EXPENSE_TYPE_CATEGORIES)` in both DTOs.","suggestion_code":null,"existing_code":" @Matches(/\\S/)\n name: string;\n\n @IsOptional()\n @IsIn(['room', 'personal', 'both'])\n category?: string;"}
{"path":"apps/server/src/expense-types/dto/expense-type.dto.ts","start_line":44,"end_line":45,"category":"maintainability","severity":"low","content":"API surface is inconsistent: CreateExpenseTypeDto has no way to set `enabled`, while UpdateExpenseTypeDto does, so a client cannot explicitly create a disabled expense type and must rely on the service's default. If defaulting to enabled on create is intended, document it (or add `enabled` to the create DTO for symmetry).","suggestion_code":null,"existing_code":" @IsBoolean()\n enabled?: boolean;"}
{"path":"apps/server/src/expense-types/expense-types.module.ts","start_line":17,"end_line":19,"category":"bug","severity":"medium","content":"`onModuleInit` awaits `seedDefaults()` without any error handling. Any failure during seeding (e.g., the database is temporarily unavailable, a migration hasn't run yet, or a row violates a constraint) throws and aborts the entire application bootstrap — the HTTP server will never start. Consider wrapping the call in try/catch with error logging, or moving seeding to `onApplicationBootstrap` so a seeding failure doesn't crash startup (or at least document that a fail-fast boot is intentional).","suggestion_code":" async onModuleInit() {\n try {\n await this.service.seedDefaults();\n } catch (err) {\n this.logger.error('Failed to seed default expense types', err);\n }\n }","existing_code":" async onModuleInit() {\n await this.service.seedDefaults();\n }"}
{"path":"apps/server/src/expense-types/expense-types.controller.ts","start_line":41,"end_line":41,"category":"bug","severity":"medium","content":"The operation log is awaited without error handling after a successful DB write. If `logService.log` throws (e.g., DB insert failure), the whole request returns 500 even though the expense type was already created — the client may then retry and hit a duplicate-code conflict or create duplicates. Operation logging should be best-effort: wrap in try/catch (and log the failure) or don't block the response. The same pattern exists in `update` and `remove`.","suggestion_code":null,"existing_code":" await this.logService.log({"}
{"path":"apps/server/src/expense-types/expense-types.controller.ts","start_line":39,"end_line":40,"category":"maintainability","severity":"low","content":"The `extractRequestInfo` + `logService.log` block is duplicated across `create`, `update`, and `remove`. Extract a private helper (e.g., `logOperation(req, action, targetId, detail)`) to reduce duplication and make the logging behavior (including error handling) consistent.","suggestion_code":null,"existing_code":" const { ipAddress, userAgent } = extractRequestInfo(req);\n const result = await this.service.create(dto);"}
{"path":"apps/server/src/expense-types/expense-types.controller.ts","start_line":59,"end_line":59,"category":"maintainability","severity":"low","content":"`ParseIntPipe` already converts `id` to a number, so the unary `+` is a redundant no-op. Use `id` directly (same for the `remove` handler).","suggestion_code":null,"existing_code":" const result = await this.service.update(+id, dto);"}
{"path":"apps/server/src/expense-types/expense-types.controller.ts","start_line":78,"end_line":78,"category":"maintainability","severity":"low","content":"Same as `update`: `ParseIntPipe` already yields a number, so `+id` is redundant — use `id` directly.","suggestion_code":null,"existing_code":" await this.service.remove(+id);"}
{"path":"apps/server/src/exams/exams.controller.ts","start_line":40,"end_line":42,"category":"bug","severity":"high","content":"`exam:edit` is listed in DEPRECATED_PERMISSION_CODES (rbac-presets.ts) and is actively removed from every role and deleted from the permission table by rbac-seed.service.ts. As a result, for any non-superadmin user `req.user.permissions.includes('exam:edit')` can never be true, so the \"manage all\" branch is effectively dead code and users can never obtain global scope through RBAC (e.g., a user holding the active `exam:purge` permission would still be restricted to their own scope when purging). Use an active permission code or rework the scope model.","suggestion_code":" private canManageAll(req: AuthenticatedRequest) {\n return req.user.isSuperAdmin || req.user.permissions.includes('exam:view');\n }","existing_code":" private canManageAll(req: AuthenticatedRequest) {\n return req.user.isSuperAdmin || req.user.permissions.includes('exam:edit');\n }"}
{"path":"apps/server/src/exams/exams.controller.ts","start_line":85,"end_line":87,"category":"security","severity":"high","content":"Mutating endpoints (create, archive, restore, batch-archive, batch-restore, updateScore) are protected only by `@RequirePermission('exam:view')`, a view-level permission. Any user granted read access can create exams, archive/restore them, and overwrite scores — inconsistent with every other module in this codebase where create/edit/delete operations require dedicated `:create`/`:edit`/`:delete` codes. Since `exam:create`/`exam:edit` were deprecated, at minimum introduce a distinct edit permission for these mutations (or enforce an explicit scope/ownership check at the guard level) to preserve least privilege.","suggestion_code":null,"existing_code":" @Post()\n @RequirePermission('exam:view')\n async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) {"}
{"path":"apps/server/src/exams/exams.controller.ts","start_line":164,"end_line":164,"category":"maintainability","severity":"low","content":"`dto.score === null || dto.score === undefined` is evaluated three times in this handler. Extract it to a local constant to avoid duplication and keep the action/detail logic consistent.","suggestion_code":" const isCleared = dto.score === null || dto.score === undefined;\n await logAudit(this.logService, req, {\n module: '考试管理', action: isCleared ? '清空成绩' : '录入成绩', targetId: scoreId, targetType: 'exam_score', detail: isCleared ? '成绩已清空' : `成绩:${dto.score}`,\n });","existing_code":" module: '考试管理', action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩', targetId: scoreId, targetType: 'exam_score', detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`,"}
{"path":"apps/server/src/exams/dto/exam.dto.ts","start_line":31,"end_line":32,"category":"maintainability","severity":"low","content":"Type-safety issue in the custom transform: when `value` doesn't match any recognized input (e.g., an invalid or empty string), the raw value is returned while being cast `as boolean`. It only 'works' because `@IsBoolean()` later rejects the invalid value, but the cast asserts a type contract the code doesn't actually guarantee and can mask future refactoring mistakes. Since `value` is already typed as `any` in `TransformFnParams`, the cast is unnecessary — remove it (or return an explicit boolean) so the transform's contract is honest.","suggestion_code":" if (value === 'false' || value === '0') return false;\n return value;","existing_code":" if (value === 'false' || value === '0') return false;\n return value as boolean;"}
{"path":"apps/server/src/exams/dto/exam.dto.ts","start_line":39,"end_line":39,"category":"maintainability","severity":"low","content":"`999.99` is a business rule (maximum allowed score) hardcoded inline. Per the hardcoding guideline, extract it into a named constant (e.g., `MAX_EXAM_SCORE`) so the rule is documented and reused consistently. Also note that `@Max` is inclusive (`score <= 999.99`); confirm that boundary is the intended one.","suggestion_code":null,"existing_code":"@IsOptional() @IsNumber() @Min(0) @Max(999.99) score?: number | null;"}
{"path":"apps/server/src/exams/dto/exam.dto.ts","start_line":23,"end_line":25,"category":"maintainability","severity":"low","content":"`examType` and `classId` are declared with near-identical validation in both `CreateExamDto` and `QueryExamDto` (only `@IsOptional`/`@Type` differ). Consider extracting the shared fields into a base DTO or shared partial so the validation rules can't drift between the create and query paths.","suggestion_code":null,"existing_code":" @IsOptional() @IsString() keyword?: string;\n @IsOptional() @IsString() examType?: string;\n @IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number;"}
{"path":"apps/server/src/expense-types/expense-types.service.ts","start_line":55,"end_line":57,"category":"bug","severity":"high","content":"`remove()` soft-deletes a type by setting `enabled=false`, but this uniqueness check matches soft-deleted rows as well. As a result, re-creating a previously removed type with the same code always throws ConflictException, and `seedDefaults()` will never restore a disabled default type either. Scope the uniqueness check to enabled records only (and consider re-enabling soft-deleted defaults in `seedDefaults`).","suggestion_code":" const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };\n const exists = await this.repo.findOne({ where: { code: normalized.code, enabled: true } });\n if (exists) throw new ConflictException('费用类型代码已存在');","existing_code":" const normalized = { ...dto, code: dto.code.trim(), name: dto.name.trim() };\n const exists = await this.repo.findOne({ where: { code: normalized.code } });\n if (exists) throw new ConflictException('费用类型代码已存在');"}
{"path":"apps/server/src/expense-types/expense-types.service.ts","start_line":61,"end_line":65,"category":"bug","severity":"medium","content":"`update` trims `name` but not `code` and performs no uniqueness validation, unlike `create`. A caller can set `code` to a value already used by another type (including a soft-deleted one), leading to duplicate codes and inconsistent data. Trim the code and check for conflicts against enabled records excluding the current id before saving.","suggestion_code":null,"existing_code":" async update(id: number, dto: UpdateExpenseTypeDto): Promise<ExpenseType> {\n const t = await this.findOne(id);\n Object.assign(t, dto, dto.name === undefined ? {} : { name: dto.name.trim() });\n return this.repo.save(t);\n }"}
{"path":"apps/server/src/expense-types/expense-types.service.ts","start_line":26,"end_line":33,"category":"performance","severity":"low","content":"The find-then-save operations for each default type are independent, so they can be executed in parallel with `Promise.all` instead of awaiting sequentially. Also note the soft-delete interaction: a default type disabled via `remove()` will never be re-created here because the row still exists — if defaults should be restorable, re-enable existing disabled rows instead of skipping them.","suggestion_code":null,"existing_code":" async seedDefaults(): Promise<void> {\n for (const t of DEFAULT_TYPES) {\n const exists = await this.repo.findOne({ where: { code: t.code } });\n if (!exists) {\n await this.repo.save(this.repo.create(t));\n }\n }\n }"}
{"path":"apps/server/src/expenses/dto/expense.dto.ts","start_line":23,"end_line":29,"category":"bug","severity":"medium","content":"No validation enforces that periodEnd >= periodStart. A client can submit a range where end precedes start (e.g., periodStart='2026-08-01', periodEnd='2026-01-01'), and this DTO will pass it through to the service. Add cross-field validation (e.g., a custom validator or service-level check) for CreateRoomExpenseDto, BatchRoomExpenseDto, and CreateStudentUtilityBillDto. The same applies to QueryRoomExpenseDto where a reversed range can silently return an empty result.","suggestion_code":null,"existing_code":" @IsDateString()\n periodEnd: string;\n\n @IsOptional()\n @IsString()\n description?: string;\n}"}
{"path":"apps/server/src/expenses/dto/expense.dto.ts","start_line":138,"end_line":140,"category":"bug","severity":"medium","content":"These period fields are validated only by a format regex (`^\\d{4}-\\d{2}-\\d{2}$`), which accepts impossible dates such as '2026-99-99' or '2026-02-30'. This is inconsistent with the other DTOs in this file (CreateRoomExpenseDto / CreatePersonalExpenseDto) that also use `@IsISO8601({ strict: true })` to verify the value is a real calendar date. Add the strict ISO8601 check here as well.","suggestion_code":" @IsString()\n @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601({ strict: true })\n periodStart: string;","existing_code":" @IsString()\n @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n periodStart: string;"}
{"path":"apps/server/src/expenses/dto/expense.dto.ts","start_line":16,"end_line":19,"category":"maintainability","severity":"low","content":"Redundant date validators: `@IsDateString()` is fully subsumed by `@IsISO8601({ strict: true })` (strict ISO8601 is a stricter subset of IsDateString), and `@Matches` already pins the exact YYYY-MM-DD format. Having all three decorators is confusing and gives no extra protection. Keep `@Matches` (to enforce date-only format) plus `@IsISO8601({ strict: true })`, and drop `@IsDateString()`. This pattern appears in CreateRoomExpenseDto, CreatePersonalExpenseDto, and BatchRoomExpenseDto.","suggestion_code":" @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601({ strict: true })\n periodStart: string;","existing_code":" @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601({ strict: true })\n @IsDateString()\n periodStart: string;"}
{"path":"apps/server/src/expenses/dto/expense.dto.ts","start_line":71,"end_line":73,"category":"maintainability","severity":"low","content":"Inconsistent date validation: create/update DTOs require strict date-only strings (`YYYY-MM-DD` via `@Matches` + `@IsISO8601({ strict: true })`), but the query DTOs only use `@IsDateString()`, which also accepts full ISO timestamps (e.g., '2026-01-01T12:00:00.000Z'). If the query parameters are compared against date-only columns, values like these can cause unexpected filtering behavior. Align the query DTO validators with the create DTOs for a consistent contract.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsDateString()\n periodEnd?: string;"}
{"path":"apps/server/src/exams/exams.service.ts","start_line":39,"end_line":43,"category":"maintainability","severity":"medium","content":"Nested ternary expression (prohibited by review rules) combined with a `-1` magic sentinel to fake \"no match\". This is fragile: if any class ever has id <= 0, or the access-check logic changes, it silently returns wrong results instead of denying access. Also, silently returning [] for a class the teacher cannot access is inconsistent with `assertClassAccess` (which throws 403) used everywhere else. Refactor to if/else and return early:","suggestion_code":" if (query.classId) {\n if (!accessibleClassIds.includes(query.classId)) return [];\n where.classId = query.classId;\n } else {\n where.classId = In(accessibleClassIds);\n }","existing_code":" where.classId = query.classId\n ? accessibleClassIds.includes(query.classId)\n ? query.classId\n : -1\n : In(accessibleClassIds);"}
{"path":"apps/server/src/exams/exams.service.ts","start_line":215,"end_line":216,"category":"maintainability","severity":"medium","content":"Batch purge deletes rows one at a time in a loop and is not wrapped in a transaction. A failure partway through leaves a partially deleted batch (inconsistent state), and each delete is a separate round-trip (N queries). Collect the archived ids and delete them in a single `IN` query; wrap the whole read-check-delete flow in a transaction for atomicity.","suggestion_code":" const archivedIds = exams.filter((exam) => exam.status === 'archived').map((exam) => exam.id);\n if (archivedIds.length > 0) {\n await this.examRepo.delete({ id: In(archivedIds) });\n }\n deleted.push(...archivedIds);","existing_code":" await this.examRepo.delete(exam.id);\n deleted.push(exam.id);"}
{"path":"apps/server/src/exams/exams.service.ts","start_line":227,"end_line":228,"category":"maintainability","severity":"low","content":"TOCTOU race: the status check (`findBatchExams`) and the status update (`updateBatchStatus`) are two separate, non-transactional steps. If an exam's status changes (or it is purged) between the read and the write, the returned `archived`/`skipped` counts and message become inaccurate. Consider wrapping the read+update in a transaction or re-checking the affected count to keep batch results consistent. The same pattern exists in `batchRestore`.","suggestion_code":null,"existing_code":" const targetIds = exams.filter((exam) => exam.status === 'active').map((exam) => exam.id);\n const archived = await this.updateBatchStatus(targetIds, 'archived');"}
{"path":"apps/server/src/exams/exams.service.ts","start_line":127,"end_line":128,"category":"performance","severity":"low","content":"`findAll` eagerly fetches all matching exams and aggregates their score rows, but only the first `limit` results are actually used here. For agents with many exams this performs full aggregation (a second query over all score rows) for exams that are immediately sliced away. Consider adding a `take`/limit parameter to `findAll` so it stops after `limit` results instead of post-processing everything.","suggestion_code":null,"existing_code":" const limit = Math.max(1, Math.min(query?.limit ?? 20, 50));\n return exams.slice(0, limit).map((exam) => ({"}
{"path":"apps/server/src/exams/exams.service.ts","start_line":172,"end_line":172,"category":"bug","severity":"medium","content":"No validation is applied to the incoming `score` before persistence. The column is `decimal(5,2)` (max 999.99), so out-of-range values will fail at the DB layer, and non-finite values (if they ever reach here) would silently corrupt the class average/rank computed by `recalculate`. Validate that the score is a finite number within a sensible range (e.g., 0150/999.99) before saving.","suggestion_code":null,"existing_code":" row.score = score === undefined ? null : score;"}
{"path":"apps/server/src/financial-operations/financial-operations.service.ts","start_line":35,"end_line":40,"category":"bug","severity":"high","content":"Non-atomic retry claim can cause duplicate execution. When an existing operation is 'failed' (or any non-completed/non-running state), the transition to 'running' is a read-modify-write (findOne → save) with no locking. Two concurrent retry requests can both read status='failed', both save 'running', and both execute work() — for financial operations this can produce duplicate payments/refunds. Use an atomic conditional claim (UPDATE ... WHERE status='failed' and check affected rows) or a pessimistic write lock within a transaction so only one request claims the operation; also re-fetch the row after claiming so the later status updates use fresh data.","suggestion_code":" } else {\n const claim = await this.repo.update(\n { operationId, status: 'failed' },\n { status: 'running', errorMessage: null, resultJson: null },\n );\n if (!claim.affected) {\n const fresh = await this.repo.findOne({ where: { operationId } });\n if (fresh?.status === 'completed' && fresh.resultJson) return JSON.parse(fresh.resultJson) as T;\n throw new ConflictException('该操作正在处理中,请勿重复提交');\n }\n operation = (await this.repo.findOne({ where: { operationId } }))!;\n }","existing_code":" } else {\n operation.status = 'running';\n operation.errorMessage = null;\n operation.resultJson = null;\n await this.repo.save(operation);\n }"}
{"path":"apps/server/src/financial-operations/financial-operations.service.ts","start_line":28,"end_line":34,"category":"bug","severity":"medium","content":"The catch block assumes every save failure is a duplicate-key error. If the insert fails for another reason (e.g., a transient DB/connection error), the code re-fetches and, finding no completed concurrent row, throws ConflictException — masking a real infrastructure failure as \"operation in progress\" and losing the original error detail. Only treat the failure as a concurrency conflict when it is actually a unique-constraint violation (e.g., Postgres code 23505 / MySQL ER_DUP_ENTRY); otherwise rethrow the original error.","suggestion_code":" } catch (error) {\n const code = (error as { driverError?: { code?: string }; code?: string })?.driverError?.code ?? (error as { code?: string })?.code;\n if (code !== '23505' && code !== 'ER_DUP_ENTRY') throw error;\n const concurrent = await this.repo.findOne({ where: { operationId } });\n if (concurrent?.status === 'completed' && concurrent.resultJson) {\n return JSON.parse(concurrent.resultJson) as T;\n }\n throw new ConflictException('该操作正在处理中,请勿重复提交', { cause: error });\n }","existing_code":" } catch (error) {\n const concurrent = await this.repo.findOne({ where: { operationId } });\n if (concurrent?.status === 'completed' && concurrent.resultJson) {\n return JSON.parse(concurrent.resultJson) as T;\n }\n throw new ConflictException('该操作正在处理中,请勿重复提交', { cause: error });\n }"}
{"path":"apps/server/src/financial-operations/financial-operations.service.ts","start_line":21,"end_line":21,"category":"bug","severity":"medium","content":"A 'running' record has no timeout/lease, so if the worker crashes (or times out) mid-execution, the row stays 'running' forever and every subsequent retry fails with ConflictException — the operation is permanently blocked even though nothing is actually executing. Consider reclaiming stale 'running' records (e.g., update updated_at as a heartbeat and treat records older than a threshold as abandoned), or provide an explicit admin reset path.","suggestion_code":null,"existing_code":" if (existing.status === 'running') throw new ConflictException('该操作正在处理中,请勿重复提交');"}
{"path":"apps/server/src/financial-operations/financial-operations.service.ts","start_line":20,"end_line":20,"category":"bug","severity":"low","content":"JSON.parse of the cached resultJson can throw (corrupt/truncated data), which would surface as an unhandled 500. Consider wrapping the parse in try/catch and falling back to re-execution or a clear error, so a bad cached payload doesn't permanently break the operation path.","suggestion_code":null,"existing_code":" if (existing.status === 'completed' && existing.resultJson) return JSON.parse(existing.resultJson) as T;"}
{"path":"apps/server/src/expenses/expenses.controller.ts","start_line":354,"end_line":357,"category":"bug","severity":"high","content":"Missing null check for the uploaded file: if the request contains no `file` part (e.g., wrong field name or file not attached), `file` is `undefined` and `file.buffer` throws a TypeError, producing an unhandled 500. Also, `workbook.xlsx.load` throws a generic error for invalid/non-xlsx content with no user-friendly message. Add a guard and wrap parsing in try/catch, throwing BadRequestException (imported from '@nestjs/common').","suggestion_code":" @UseInterceptors(FileInterceptor('file'))\n async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {\n if (!file) throw new BadRequestException('请上传 Excel 文件');\n const workbook = new ExcelJS.Workbook();\n try {\n await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));\n } catch {\n throw new BadRequestException('文件格式无效,请上传 .xlsx 模板文件');\n }","existing_code":" @UseInterceptors(FileInterceptor('file'))\n async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {\n const workbook = new ExcelJS.Workbook();\n await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));"}
{"path":"apps/server/src/expenses/expenses.controller.ts","start_line":419,"end_line":422,"category":"bug","severity":"high","content":"Same issue as the utility import endpoint: `file` can be `undefined` when the request has no `file` part, and `file.buffer` will throw an unhandled TypeError (500). Invalid Excel content also surfaces as a generic error. Add a null guard and a try/catch that throws a user-friendly BadRequestException.","suggestion_code":" @UseInterceptors(FileInterceptor('file'))\n async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {\n if (!file) throw new BadRequestException('请上传 Excel 文件');\n const workbook = new ExcelJS.Workbook();\n try {\n await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));\n } catch {\n throw new BadRequestException('文件格式无效,请上传 .xlsx 模板文件');\n }","existing_code":" @UseInterceptors(FileInterceptor('file'))\n async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {\n const workbook = new ExcelJS.Workbook();\n await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));"}
{"path":"apps/server/src/expenses/expenses.controller.ts","start_line":417,"end_line":419,"category":"security","severity":"medium","content":"FileInterceptor is used without `limits` or `fileFilter` on both import endpoints. The entire uploaded file is buffered in memory (`file.buffer`) and fully parsed by ExcelJS; an arbitrarily large file can exhaust server memory (DoS). Add size limits (e.g. `limits: { fileSize: 5 * 1024 * 1024 }`) and restrict to `.xlsx` via `fileFilter` for both `/utility/import` and `/personal/import`.","suggestion_code":" @Post('personal/import')\n @RequirePermission('expense:create')\n @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (_req, f, cb) => cb(null, /xlsx$/.test(f.originalname)) }))","existing_code":" @Post('personal/import')\n @RequirePermission('expense:create')\n @UseInterceptors(FileInterceptor('file'))"}
{"path":"apps/server/src/expenses/expenses.controller.ts","start_line":98,"end_line":99,"category":"maintainability","severity":"low","content":"Dead branch: `v` was already checked for null at the top of `readCell` (`if (v == null) return '';`), so at this final line the ternary always evaluates to `''`. Simplify to avoid misleading code.","suggestion_code":" if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;\n return '';","existing_code":" if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;\n return v == null ? v : '';"}
{"path":"apps/server/src/expenses/expenses.controller.ts","start_line":180,"end_line":182,"category":"maintainability","severity":"low","content":"Unlike the batch-restore endpoints (which use BatchIdsDto with `whitelist`/`forbidNonWhitelisted`), these batch-delete/purge endpoints accept a raw, unvalidated body and silently fall back to `[]` when `ids` is missing or invalid (`body.ids || []`) — an invalid request silently deletes nothing. For consistency and correctness, use BatchIdsDto with ValidationPipe here too (applies to batchDelete/batchPurge for both room and personal expenses).","suggestion_code":" @Post('room/batch-delete')\n @RequirePermission('expense:delete')\n @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))\n async batchDeleteRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {","existing_code":" @Post('room/batch-delete')\n @RequirePermission('expense:delete')\n async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {"}
{"path":"apps/server/src/imports/entities/import-step.entity.ts","start_line":29,"end_line":29,"category":"maintainability","severity":"low","content":"The default status 'pending' is a hardcoded business string. `imports.types.ts` already centralizes step/status domain values, so a shared constant (e.g. `DEFAULT_STEP_STATUS = 'pending'`) should be exported and referenced here to keep the single source of truth and avoid drift if the default status ever changes.","suggestion_code":"@Column({ name: 'status', type: 'varchar', length: 20, default: DEFAULT_STEP_STATUS })","existing_code":"@Column({ type: 'varchar', length: 20, default: 'pending' })"}
{"path":"apps/server/src/imports/entities/import-step.entity.ts","start_line":30,"end_line":30,"category":"maintainability","severity":"low","content":"All sibling columns explicitly declare snake_case `name` (e.g. `run_id`, `step_key`, `sheets_json`), but `status` relies on the default naming strategy. Today this happens to resolve to `status`, so it works, but it is inconsistent with the rest of the entity and would silently break if a custom naming strategy (or column rename) is introduced. Add an explicit `name: 'status'` for consistency.","suggestion_code":" @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' })\n status: ImportStepStatus;","existing_code":" status: ImportStepStatus;"}
{"path":"apps/server/src/expenses/expenses.service.ts","start_line":138,"end_line":144,"category":"bug","severity":"high","content":"`status` is returned in the result mapping (`status: String(row.status)`) but `e.status` is never added to `roomExpenseSelects`. `getRawMany` only returns the selected columns, so `row.status` is always `undefined` and every agent-search result will report `status: \"undefined\"` instead of `active`. Add `['e.status', 'status']` to the selects (and likewise for the personal-expense query below).","suggestion_code":" const roomExpenseSelects = [\n ['e.expenseType', 'expenseType'],\n ['e.amount', 'amount'],\n ['e.periodStart', 'periodStart'],\n ['e.periodEnd', 'periodEnd'],\n ['room.roomNumber', 'roomNumber'],\n ['e.status', 'status'],\n ] as const;","existing_code":" const roomExpenseSelects = [\n ['e.expenseType', 'expenseType'],\n ['e.amount', 'amount'],\n ['e.periodStart', 'periodStart'],\n ['e.periodEnd', 'periodEnd'],\n ['room.roomNumber', 'roomNumber'],\n ] as const;"}
{"path":"apps/server/src/expenses/expenses.service.ts","start_line":167,"end_line":173,"category":"bug","severity":"high","content":"Same issue as the room query: `personalExpenseSelects` does not include `e.status`, yet the mapping below calls `String(row.status)`. Since `getRawMany` only returns selected columns, `row.status` is `undefined` and every result reports `status: \"undefined\"`. Add `['e.status', 'status']` to the selects.","suggestion_code":" const personalExpenseSelects = [\n ['e.expenseType', 'expenseType'],\n ['e.amount', 'amount'],\n ['e.expenseDate', 'expenseDate'],\n ['student.name', 'studentName'],\n ['student.studentNo', 'studentNo'],\n ['e.status', 'status'],\n ] as const;","existing_code":" const personalExpenseSelects = [\n ['e.expenseType', 'expenseType'],\n ['e.amount', 'amount'],\n ['e.expenseDate', 'expenseDate'],\n ['student.name', 'studentName'],\n ['student.studentNo', 'studentNo'],\n ] as const;"}
{"path":"apps/server/src/expenses/expenses.service.ts","start_line":199,"end_line":201,"category":"bug","severity":"medium","content":"`String(row.periodStart)` / `String(row.periodEnd)` will not produce the `YYYY-MM-DD` format declared by the return type when the DB driver returns `Date` objects (the MySQL driver returns `Date` by default and no `dateStrings` option was found in this project). `String(date)` yields a locale-dependent full timestamp (e.g. \"Sat Aug 09 2026 08:00:00 GMT+0800 ...\"), which can break downstream agent consumers that expect date strings. Format explicitly with dayjs (used elsewhere in this file) and guard nulls, e.g. `row.periodStart == null ? '' : dayjs(row.periodStart).format('YYYY-MM-DD')`. Same applies to `expenseDate` in the personal mapping.","suggestion_code":" amount: Number(row.amount),\n periodStart: row.periodStart == null ? '' : dayjs(row.periodStart).format('YYYY-MM-DD'),\n periodEnd: row.periodEnd == null ? '' : dayjs(row.periodEnd).format('YYYY-MM-DD'),","existing_code":" amount: Number(row.amount),\n periodStart: String(row.periodStart),\n periodEnd: String(row.periodEnd),"}
{"path":"apps/server/src/expenses/expenses.service.ts","start_line":295,"end_line":304,"category":"bug","severity":"medium","content":"This loop performs multiple `roomExpRepo.delete` calls without a transaction. If a later delete fails (or a bill is attached between the `bill_items` count above and the delete), earlier records are already permanently removed, leaving a partially applied operation with no rollback. Wrap the purge in `dataSource.transaction` and delete via the transaction manager to make the batch delete atomic.","suggestion_code":null,"existing_code":" const deleted: number[] = [];\n const skipped: string[] = [];\n for (const e of existing) {\n if (e.status !== 'archived') {\n skipped.push(`记录${e.id}(未归档)`);\n continue;\n }\n await this.roomExpRepo.delete(e.id);\n deleted.push(e.id);\n }"}
{"path":"apps/server/src/expenses/expenses.service.ts","start_line":312,"end_line":316,"category":"bug","severity":"low","content":"`updateRoomExpense` does not check `e.status`, so an already-archived expense can still be modified. This is inconsistent with `deleteRoomExpense`/`purgeRoomExpense`, which explicitly forbid archived records, and can silently resurrect or mutate data the user intended to be frozen. Also note the billed check is executed before the existence check, so the error message for a non-existent id is misleadingly tied to billing; check existence first.","suggestion_code":null,"existing_code":" async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {\n const e = await this.roomExpRepo.findOne({ where: { id } });\n const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });\n if (billed) throw new BadRequestException('已计入账单的宿舍费用不能修改,请先取消账单');\n if (!e) throw new NotFoundException('费用记录不存在');"}
{"path":"apps/server/src/expenses/expenses.service.ts","start_line":220,"end_line":221,"category":"maintainability","severity":"low","content":"The raw repository name string `'bill_items'` is used in six methods here; it silently breaks if the entity/table is renamed and provides no type safety. Additionally `dataSource?.` optional chaining is unnecessary because `dataSource` is a required constructor dependency (and other methods call it without `?.`). Prefer a typed repository (e.g. `@InjectRepository(BillItem)`) or at least keep the lookup consistent.","suggestion_code":null,"existing_code":" const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });\n if (billed) throw new BadRequestException('已计入账单的宿舍费用不能归档,请先取消账单');"}
{"path":"apps/server/src/imports/entities/import-run.entity.ts","start_line":30,"end_line":31,"category":"maintainability","severity":"low","content":"The DB-level default `'preparing'` for `status` (and similarly `'manual'` for `source`) is dead configuration: the only creation path (`ImportRunService.createRun` in imports.run.service.ts) always sets `status: 'ready'` and `source` explicitly, and `'preparing'` never appears anywhere in the app. A dead default can silently mask missing-field bugs in future code paths (a run created without `status` would land in an unhandled state) and makes the schema misleading. Consider removing the defaults, or aligning them with the actual creation flow.","suggestion_code":null,"existing_code":" @Column({ type: 'varchar', length: 20, default: 'preparing' })\n status: ImportRunStatus;"}
{"path":"apps/server/src/imports/entities/import-run.entity.ts","start_line":36,"end_line":37,"category":"maintainability","severity":"low","content":"The length 500 is a magic number duplicated at the write site (`imports.commit.service.ts: run.error = safeError(error).slice(0, 500)`). If either side changes without the other, errors will either be silently truncated or the column will overflow (raising a 'Data too long' error under strict SQL mode). Suggest extracting a shared constant (e.g., `MAX_RUN_ERROR_LENGTH`) or widening the column to `TEXT` to eliminate the drift risk.","suggestion_code":null,"existing_code":" @Column({ type: 'varchar', length: 500, nullable: true })\n error: string | null;"}
{"path":"apps/server/src/imports/entities/import-row.entity.ts","start_line":5,"end_line":6,"category":"bug","severity":"medium","content":"No unique constraint guards against duplicate rows for the same source row. The preview flow (imports.preview.service.ts) deletes rows by stepId and then re-inserts them, so a concurrent/double preview invocation can insert the same source row twice (the delete+insert is not a DB-level atomic operation, and nothing here prevents it). Since one step can span multiple sheets, row numbers are only unique per sheet, so the unique key should cover (runId, stepId, sheetName, rowNumber). Consider adding e.g. @Index('uk_import_rows_run_step_sheet_row', ['runId', 'stepId', 'sheetName', 'rowNumber'], { unique: true }).","suggestion_code":"@Index('idx_import_rows_step', ['stepId'])\n@Index('idx_import_rows_run_status', ['runId', 'status'])\n@Index('uk_import_rows_run_step_sheet_row', ['runId', 'stepId', 'sheetName', 'rowNumber'], { unique: true })","existing_code":"@Index('idx_import_rows_step', ['stepId'])\n@Index('idx_import_rows_run_status', ['runId', 'status'])"}
{"path":"apps/server/src/imports/entities/import-row.entity.ts","start_line":44,"end_line":45,"category":"maintainability","severity":"low","content":"Rows are mutated after creation (status transitions pending→valid→committed, errors_json set/cleared, target_id filled in during commit), yet the entity only has created_at and no update tracking. The sibling ImportRun entity defines @UpdateDateColumn({ name: 'updated_at' }), so this is an inconsistency that makes it impossible to audit when a row's status last changed. Consider adding an updated_at column.","suggestion_code":" @CreateDateColumn({ name: 'created_at' })\n createdAt: Date;\n\n @UpdateDateColumn({ name: 'updated_at' })\n updatedAt: Date;","existing_code":" @CreateDateColumn({ name: 'created_at' })\n createdAt: Date;"}
{"path":"apps/server/src/expenses/expense-operations.service.ts","start_line":245,"end_line":245,"category":"bug","severity":"medium","content":"Utility fees are only checked with `fee > 0`, but never go through `assertPositiveAmount` (finite / max 2 decimals / > 0) which the personal import path uses. A value with more than 2 decimals (e.g. `100.123`) or a sub-cent positive value (`0.001`) will be written into the `decimal(10,2)` `amount` column and silently rounded/truncated by the DB (or fail the whole row in strict mode), so the stored money won't match the source. Also `totalFee` (应缴金额) is never validated or used. Suggest reusing `assertPositiveAmount` for `electricityFee`/`waterFee` before importing, and validating `totalFee` if it's expected to equal the sum.","suggestion_code":null,"existing_code":" if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {"}
{"path":"apps/server/src/expenses/expense-operations.service.ts","start_line":270,"end_line":271,"category":"bug","severity":"low","content":"`userId!` masks a possibly-`undefined` value: the method signature is `userId?: number`, but `importUtilityExpense` types `recordedBy: number`. If the caller doesn't provide a user, `recordedBy` receives `undefined` at runtime and NULL is silently persisted in `recorded_by` (column is nullable). Either type the parameter as `number | undefined` (and persist it as-is) or explicitly reject/require `userId` when importing.","suggestion_code":null,"existing_code":" byType,\n userId!,"}
{"path":"apps/server/src/expenses/expense-operations.service.ts","start_line":64,"end_line":66,"category":"bug","severity":"low","content":"Unlike the sibling methods `batchRestorePersonalExpenses`/`batchPurgePersonalExpenses`, this method never validates IDs with `Number.isInteger(id) && id > 0`. Additionally, unlike the single `deletePersonalExpense` (which throws on already-archived records), the batch path will silently re-archive already-archived records and count them in `result.affected`, inflating the reported \"archived\" count. Add the same integer-ID validation and decide whether already-archived records should be skipped instead of counted.","suggestion_code":null,"existing_code":" async batchDeletePersonalExpenses(ids: number[]) {\n const uniqueIds = [...new Set(ids || [])];\n if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');"}
{"path":"apps/server/src/expenses/expense-operations.service.ts","start_line":160,"end_line":163,"category":"bug","severity":"low","content":"`dto.studentId` existence is validated before `Object.assign(e, dto)`, but `dto.roomId` is not — and the same gap exists in `createPersonalExpense` (`this.personalExpRepo.create({ ...dto, recordedBy: userId })`). Since `personal_expenses.room_id` has no FK/relation defined in the entity, an invalid `roomId` is silently persisted as an orphan reference that will surface as bad data in reports. Validate room existence whenever `roomId` is provided.","suggestion_code":null,"existing_code":" if (dto.studentId !== undefined && dto.studentId !== e.studentId) {\n const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });\n if (!student) throw new NotFoundException('学生不存在');\n }"}
{"path":"apps/server/src/expenses/expense-operations.service.ts","start_line":189,"end_line":191,"category":"performance","severity":"low","content":"Each row in this loop performs several sequential awaited DB round trips (room find/create, existing expense lookup, per-type save), making an Excel import O(rows × ~5) sequential queries. Rows are independent, so consider processing with bounded concurrency or batching (keeping per-row error collection) to cut latency for large files — taking care to handle races on the unique `room_number`/`import_key` constraints (e.g., retry on duplicate-key). The same pattern applies to `batchImportPersonalExpenses`.","suggestion_code":null,"existing_code":" for (let i = 0; i < rows.length; i++) {\n const row = rows[i];\n const rowNum = i + 2;"}
{"path":"apps/server/src/imports/imports.lookups.ts","start_line":91,"end_line":92,"category":"performance","severity":"medium","content":"When any organization name appears in the import, this loads the ENTIRE organizations table instead of just the referenced rows. For a large organization table this wastes bandwidth/memory and adds latency to every student import. Filter by the collected names with `In([...organizationNames])`.","suggestion_code":" const organizations =\n organizationNames.size > 0\n ? await dataSource.getRepository(Organization).find({\n where: { name: In([...organizationNames]) },\n })\n : [];","existing_code":" const organizations =\n organizationNames.size > 0 ? await dataSource.getRepository(Organization).find() : [];"}
{"path":"apps/server/src/imports/imports.lookups.ts","start_line":61,"end_line":67,"category":"bug","severity":"medium","content":"For large import files, `studentNos`/`phones`/`roomNumbers` can contain thousands of entries, and the `In([...])` clauses built here (and for Room below) will exceed the query parameter limit of the underlying DB (e.g. SQLite ~999/32766 variables, PostgreSQL 65535) or cause a severe query-plan slowdown. Consider chunking the IN lists (e.g. batches of 500-1000) or running batched queries and merging results.","suggestion_code":null,"existing_code":" if (studentNos.size > 0) {\n students.push(\n ...(await dataSource.getRepository(Student).find({\n where: { studentNo: In([...studentNos]) },\n })),\n );\n }"}
{"path":"apps/server/src/imports/imports.lookups.ts","start_line":60,"end_line":74,"category":"performance","severity":"low","content":"The two student lookups (by studentNo and by phone) are independent and executed sequentially, doubling the round-trip latency. Run them in parallel with `Promise.all` (and consider merging the results into one combined query with an OR condition if the entity supports it).","suggestion_code":null,"existing_code":" const students: Student[] = [];\n if (studentNos.size > 0) {\n students.push(\n ...(await dataSource.getRepository(Student).find({\n where: { studentNo: In([...studentNos]) },\n })),\n );\n }\n if (phones.size > 0) {\n students.push(\n ...(await dataSource.getRepository(Student).find({\n where: { phone: In([...phones]) },\n })),\n );\n }"}
{"path":"apps/server/src/imports/imports.commit.service.ts","start_line":201,"end_line":201,"category":"security","severity":"medium","content":"CSV formula injection (security): `csvCell` only escapes double quotes (`\"\"`), but the raw data written here comes directly from user-uploaded files (untrusted). If any imported cell value starts with `=`, `+`, `-`, or `@` (e.g. `=HYPERLINK(...)` / `=cmd|...`), opening the exported report in Excel/Sheets will interpret it as a formula and execute it. The same applies to `csvCell(errors.join(''))` since DB error messages may embed user data. Fix: neutralize leading formula characters in `csvCell` (prefix with `'` or tab) or sanitize before writing.","suggestion_code":null,"existing_code":" csvCell(JSON.stringify(raw)),"}
{"path":"apps/server/src/imports/imports.commit.service.ts","start_line":102,"end_line":105,"category":"bug","severity":"high","content":"Concurrency hazard: the run/step transition to 'committing' is a plain `save` outside any transaction, lock, or optimistic-version column (no @Version exists in the module). Two concurrent `commitStep` calls for the same run can both pass the earlier status checks (both see `ready`) and both run the write transaction, producing duplicate created/updated records. Use an atomic conditional update (e.g. `UPDATE ... WHERE status='ready'` and check affected rows) or a pessimistic/optimistic lock around the state transition.","suggestion_code":null,"existing_code":" run.status = 'committing';\n step.status = 'committing';\n await this.runs.save(run);\n await this.steps.save(step);"}
{"path":"apps/server/src/imports/imports.commit.service.ts","start_line":136,"end_line":139,"category":"bug","severity":"medium","content":"Recovery bug: if the write transaction throws, `run` is set to `failed`, but `step.status` was already saved as `committing` before the transaction and is never reset. Any subsequent retry hits `if (step.status !== 'ready')` and throws '尚未预览', so the run/step is permanently stuck with no recovery path (the user can neither retry nor re-preview). Reset `step.status` back to `ready` (or `failed`) in the catch block so the run can be recovered.","suggestion_code":null,"existing_code":" run.status = 'failed';\n run.error = safeError(error).slice(0, 500);\n await this.runs.save(run);\n throw new ConflictException(`提交失败:${safeError(error)}`);"}
{"path":"apps/server/src/imports/imports.commit.service.ts","start_line":111,"end_line":111,"category":"bug","severity":"medium","content":"Silent default to `create` for undecided rows: valid rows not present in `decisions` and lacking a preview `row.action` will be automatically created here. This contradicts the earlier validation (which only allows undecided rows to be skipped) and can commit rows the user never confirmed. Default undecided rows to `skip` instead, or throw a clear error when `row.action` is missing for a non-decided row.","suggestion_code":null,"existing_code":" const action = decisionMap.get(row.id) ?? row.action ?? 'create';"}
{"path":"apps/server/src/imports/imports.helpers.ts","start_line":55,"end_line":57,"category":"bug","severity":"medium","content":"Timezone inconsistency in the Date branch: `dayjs(value)` formats in the server's local timezone, while the string branch below computes a strictly UTC calendar date. The shared `common/dayjs` only extends the `utc` plugin but never actually parses/formats in UTC, so this depends on deployment timezone. ExcelJS returns date cells as UTC-midnight Dates; on any server west of UTC (offset < 0) the formatted date will be shifted back one day, producing off-by-one dates for valid Excel rows. Use `dayjs.utc(value).format('YYYY-MM-DD')` to stay consistent with the UTC logic of the string branch.","suggestion_code":" if (value instanceof Date && !Number.isNaN(value.getTime())) {\n return dayjs.utc(value).format('YYYY-MM-DD');\n }","existing_code":" if (value instanceof Date && !Number.isNaN(value.getTime())) {\n return dayjs(value).format('YYYY-MM-DD');\n }"}
{"path":"apps/server/src/imports/imports.helpers.ts","start_line":93,"end_line":95,"category":"security","severity":"medium","content":"CSV formula-injection risk: `csvCell` is used in `imports.commit.service.ts` to export raw user-provided row data (`JSON.stringify(raw)`, sheet names, error text) into an error-report CSV. Wrapping in double quotes does NOT stop Excel from evaluating cells whose content starts with `=`, `+`, `-`, `@`, `\\t` or `\\r`, which can execute external formulas/hyperlink payloads when the file is opened. Neutralize such leading characters (e.g., prefix with a single quote) before quoting.","suggestion_code":"export function csvCell(value: string): string {\n const escaped = value.replace(/^[=+\\-@\\t\\r]/, \"'\").replace(/\"/g, '\"\"');\n return `\"${escaped}\"`;\n}","existing_code":"export function csvCell(value: string): string {\n return `\"${value.replace(/\"/g, '\"\"')}\"`;\n}"}
{"path":"apps/server/src/imports/imports.helpers.ts","start_line":60,"end_line":60,"category":"bug","severity":"low","content":"The date regex is only anchored at the start, not the end, so inputs with arbitrary trailing garbage (e.g. `2024-01-15abc`) silently parse as a valid date instead of being rejected. If datetime values like `2024-01-15 10:30` are intended, allow only trailing whitespace/time separators; otherwise require `$`.","suggestion_code":" const match = /^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})(?=$|[\\sT])/.exec(raw);","existing_code":" const match = /^(\\d{4})[-/](\\d{1,2})[-/](\\d{1,2})/.exec(raw);"}
{"path":"apps/server/src/imports/imports.helpers.ts","start_line":43,"end_line":44,"category":"bug","severity":"low","content":"`cellValue` drops ExcelJS rich-text cells: a formatted cell is exposed as `{ richText: [{ text, font }] }`, which falls through both `candidate.text` and `candidate.result` checks and returns `null`, silently losing the visible text. A formula cell whose `result` is a boolean is also dropped. Handle `richText` (concatenate `text` fields) and boolean `result` values so those cells are imported instead of becoming empty.","suggestion_code":" if (typeof value === 'object') {\n const candidate = value as {\n text?: unknown;\n result?: unknown;\n richText?: Array<{ text?: unknown }>;\n };\n if (Array.isArray(candidate.richText)) {\n const rich = candidate.richText.map((t) => (typeof t.text === 'string' ? t.text : '')).join('');\n if (rich) return rich;\n }","existing_code":" if (typeof value === 'object') {\n const candidate = value as { text?: unknown; result?: unknown };"}
{"path":"apps/server/src/imports/imports.access.ts","start_line":41,"end_line":42,"category":"bug","severity":"medium","content":"`STEP_PERMISSIONS[stepKey]` can be `undefined` at runtime when `stepKey` is not one of the four union keys (e.g., a value coming from a request body/param that was only cast to `ImportStepKey` without validation). Calling `required.some(...)` on `undefined` throws a raw `TypeError`, surfacing as a 500 instead of a clean 4xx. Although the controller currently validates via `parseStepKey`, this helper is reusable and should not assume a valid key. Add a null guard before dereferencing `required`.","suggestion_code":" const required = STEP_PERMISSIONS[stepKey];\n if (!required) {\n throw new ForbiddenException(`未知导入阶段:${stepKey}`);\n }\n if (!required.some((code) => principal.permissions.includes(code))) {","existing_code":" const required = STEP_PERMISSIONS[stepKey];\n if (!required.some((code) => principal.permissions.includes(code))) {"}
{"path":"apps/server/src/imports/imports.access.ts","start_line":26,"end_line":26,"category":"security","severity":"low","content":"TypeORM silently omits `undefined` values when building the `where` clause. If `userId` (or `runId`) is ever `undefined` at runtime, the ownership filter is dropped and the query may return another user's run instead of throwing NotFoundException — an authorization bypass in an ownership-gate helper. Since this function is the access-control boundary for import runs, defensively validate the inputs (e.g., `Number.isInteger(userId)` and a non-empty `runId`) before querying. `findStep` below shares the same pattern.","suggestion_code":" if (!Number.isInteger(userId) || !runId) {\n throw new NotFoundException('导入任务不存在');\n }\n const run = await runs.findOne({ where: { id: runId, userId } });","existing_code":" const run = await runs.findOne({ where: { id: runId, userId } });"}
{"path":"apps/server/src/imports/imports.policies.ts","start_line":14,"end_line":20,"category":"maintainability","severity":"medium","content":"The policy classification is based on matching hardcoded Chinese UI message substrings (e.g. '请勿重复导入', '未找到宿舍') that are generated elsewhere in imports.rows.ts (lines 135-202). This couples business logic to user-facing display text: any wording change or typo in those messages will silently disable duplicate-skip / skip-unmatched behavior with no compile-time or type-level protection, and a refactor of the messages breaks policy silently. Suggest introducing structured error categories (e.g. a machine-readable error code/type added to each error, or shared constant markers) instead of string matching on localized messages.","suggestion_code":null,"existing_code":"function isDuplicateError(error: string): boolean {\n return (\n error.includes('请勿重复导入') ||\n error.includes('请勿重复换宿') ||\n error.includes('本次文件中已有')\n );\n}"}
{"path":"apps/server/src/imports/imports.policies.ts","start_line":51,"end_line":53,"category":"bug","severity":"medium","content":"When duplicatePolicy === 'skip', status is unconditionally forced to 'valid' even if non-duplicate errors remain in keptErrors (e.g. a row that is both a duplicate and also fails student/room lookup). The returned row then carries status 'valid' while errors still contain real error text — an inconsistent state that the preview summary in imports.preview.service.ts counts as valid. In addition, because the skipUnmatched branch below only triggers on `status === 'error'`, the retained reference errors never get the '未匹配学生/宿舍,按策略跳过' explanation. Consider only setting status to 'valid' when keptErrors is empty, or keying the skipUnmatched branch off the error content rather than the status flag.","suggestion_code":null,"existing_code":" if (duplicatePolicy === 'skip' && errors.some(isDuplicateError)) {\n action = 'skip';\n status = 'valid';"}
{"path":"apps/server/src/imports/imports.preview.service.ts","start_line":83,"end_line":83,"category":"bug","severity":"medium","content":"The existing preview rows are deleted BEFORE the new rows are validated and saved, and the delete + insert + step update are not wrapped in a transaction. If `buildLookups`/`validateRow` throws mid-loop (DB errors, unexpected data shapes) or `rows.save` fails, the step's previously stored preview rows are already gone while `step.summaryJson`/`step.status` remain stale, leaving inconsistent state. Two concurrent previews for the same step can also interleave delete/save and corrupt the row set. Wrap the whole delete + rebuild + save in `dataSource.transaction(...)`.","suggestion_code":null,"existing_code":" await this.rows.delete({ stepId: step.id });"}
{"path":"apps/server/src/imports/imports.preview.service.ts","start_line":71,"end_line":71,"category":"bug","severity":"medium","content":"`usedSheets` keeps duplicates if `body.sheets` contains the same sheet name more than once (or if the run's `sheetsJson` itself contains duplicate sheet entries). The same sheet would then be processed multiple times: rows are inserted twice with identical rowNumbers, and `summary.total/valid/create/...` are double-counted. The duplicates are also persisted into `step.sheetsJson`, which can propagate to the later commit phase. Deduplicate before processing.","suggestion_code":" const usedSheets = [...new Set(sheetNames.filter((name) => sheetsData.some((s) => s.name === name)))];","existing_code":" const usedSheets = sheetNames.filter((name) => sheetsData.some((s) => s.name === name));"}
{"path":"apps/server/src/imports/imports.preview.service.ts","start_line":115,"end_line":118,"category":"performance","severity":"low","content":"Inside the per-row loop, `sheet.headers.indexOf(header)` rescans the whole header array for every mapped field of every row (O(rows × fields × headers)). For large sheets this is needless repeated work — build a header→index lookup once per sheet and reuse it for all rows. This also avoids the `indexOf` returning -1 fallback being re-evaluated each iteration.","suggestion_code":" const headerIndex = new Map(sheet.headers.map((h, index) => [h, index]));\n const fields: Record<string, CellValue> = {};\n for (const [field, header] of Object.entries(sheetMapping)) {\n fields[field] = rawValues[headerIndex.get(header) ?? -1] ?? null;\n }","existing_code":" const fields: Record<string, CellValue> = {};\n for (const [field, header] of Object.entries(sheetMapping)) {\n fields[field] = rawValues[sheet.headers.indexOf(header)] ?? null;\n }"}
{"path":"apps/server/src/imports/imports.mapping.ts","start_line":77,"end_line":85,"category":"maintainability","severity":"medium","content":"This is a nested ternary chain (`? 6 : ... ? 6 : 0`), which is disallowed by the review rules and makes the scoring logic hard to read. Additionally, the alias lists here are hardcoded duplicates of IMPORT_FIELD_ALIASES (checkins.checkInDate / transfers.oldRoom|newRoom|transferDate); if aliases are later changed in imports.types, this signal logic silently drifts. Consider extracting the signal computation into a helper using if/else and deriving the alias lists from IMPORT_FIELD_ALIASES.","suggestion_code":" let signal = 0;\n if (stepKey === 'checkins') {\n if (hasAny(['入住日期', '入住时间'])) {\n signal = 6;\n } else if (hasAny(['宿舍号', '房间号', '房号']) && hasAny(['入住日期', '入住时间', '日期'])) {\n signal = 6;\n }\n } else if (\n stepKey === 'transfers' &&\n hasAny(['原宿舍', '原房间', '原宿舍号', '新宿舍', '新房间', '新宿舍号', '换宿日期', '变更日期'])\n ) {\n signal = 6;\n }","existing_code":" const signal =\n stepKey === 'checkins' &&\n (hasAny(['入住日期', '入住时间']) ||\n (hasAny(['宿舍号', '房间号', '房号']) && hasAny(['入住日期', '入住时间', '日期'])))\n ? 6\n : stepKey === 'transfers' &&\n hasAny(['原宿舍', '原房间', '原宿舍号', '新宿舍', '新房间', '新宿舍号', '换宿日期', '变更日期'])\n ? 6\n : 0;"}
{"path":"apps/server/src/imports/imports.mapping.ts","start_line":87,"end_line":89,"category":"maintainability","severity":"low","content":"`matchedFields` is being assigned the weighted total score (matched count + identity*3 + required*2 + signal), not the number of matched fields as the name/type implies. The comparison `score > best.matchedFields` only works because both sides happen to store the same weighted value; any future code reading `matchedFields` as a raw field count will be misled. Store the raw matched count in `matchedFields` and track the weighted score separately (e.g., a `score` field), or rename the property.","suggestion_code":null,"existing_code":" if (!best || score > best.matchedFields) {\n best = { stepKey, mapping, matchedFields: score };\n }"}
{"path":"apps/server/src/imports/imports.service.ts","start_line":35,"end_line":40,"category":"maintainability","severity":"medium","content":"The three sub-services (ImportRunService, ImportPreviewService, ImportCommitService) are manually instantiated with `new` here, bypassing NestJS DI. They are decorated with `@Injectable()` and `@InjectRepository` (which are inert when constructed manually), are not registered as providers in `imports.module.ts`, and any new constructor dependency must be hand-threaded through `ImportsService`. This also prevents lifecycle hooks and makes the sub-services impossible to mock/override in NestJS testing modules. Consider registering them as providers in the module and injecting them normally, e.g. adding `private readonly runService: ImportRunService` (plus the preview/commit services) to this constructor.","suggestion_code":" private get runsSvc(): ImportRunService {\n return this.runService;\n }","existing_code":" private get runsSvc(): ImportRunService {\n if (!this.runService) {\n this.runService = new ImportRunService(this.runs, this.steps);\n }\n return this.runService;\n }"}
{"path":"apps/server/src/imports/imports.types.ts","start_line":12,"end_line":17,"category":"maintainability","severity":"low","content":"`IMPORT_STEP_ORDER` duplicates `IMPORT_STEP_KEYS` verbatim (same values, same order), creating two independent sources of truth for the workflow stage sequence. A future step added to one array but not the other would silently break workflow progression (IMPORT_STEP_ORDER drives access/commit/mapping/run services) or the union type derived from IMPORT_STEP_KEYS. Since `IMPORT_STEP_KEYS` is `as const` and already fixes ordering, derive the order array from it instead: `export const IMPORT_STEP_ORDER: readonly ImportStepKey[] = IMPORT_STEP_KEYS;`.","suggestion_code":"export const IMPORT_STEP_ORDER: readonly ImportStepKey[] = IMPORT_STEP_KEYS;","existing_code":"export const IMPORT_STEP_ORDER: readonly ImportStepKey[] = [\n 'students',\n 'rooms',\n 'checkins',\n 'transfers',\n];"}
{"path":"apps/server/src/imports/imports.run.service.ts","start_line":56,"end_line":62,"category":"performance","severity":"low","content":"Multiple headerRow values cause the entire workbook to be re-parsed sequentially, one full parse per unique header row. These parses are independent of each other and can be slow for large files; run them in parallel with Promise.all instead of awaiting in a loop.","suggestion_code":" const parsedByHeaderRow = new Map<number, ImportSheetData[]>();\n await Promise.all(\n headerRows.map(async (headerRow) => {\n parsedByHeaderRow.set(\n headerRow,\n await parseSheets(file.buffer, file.originalName, file.mimeType, headerRow),\n );\n }),\n );","existing_code":" const parsedByHeaderRow = new Map<number, ImportSheetData[]>();\n for (const headerRow of headerRows) {\n parsedByHeaderRow.set(\n headerRow,\n await parseSheets(file.buffer, file.originalName, file.mimeType, headerRow),\n );\n }"}
{"path":"apps/server/src/imports/imports.run.service.ts","start_line":67,"end_line":70,"category":"bug","severity":"medium","content":"`views` is keyed only by sheet name, so if two stages reference the same sheet name but declare different `headerRow` values, the later stage silently overwrites the earlier one. The stored `sheetsJson` then only contains the last version, while steps of the earlier stage also reference the same sheet name and will consume rows/headers parsed with the wrong header row. Key by `headerRow + sheetName` (or reject duplicate sheet names across different header rows) to avoid data mismatch.","suggestion_code":null,"existing_code":" const view = parsedByHeaderRow\n .get(stage.headerRow ?? 1)\n ?.find((sheet) => sheet.name === sheetName);\n if (view) views.set(sheetName, view);"}
{"path":"apps/server/src/imports/imports.run.service.ts","start_line":127,"end_line":128,"category":"bug","severity":"low","content":"When the assigned sheets have no headers (`allowedHeaders.size === 0`), mapping validation is skipped entirely. A user-supplied `mappingByStep` referencing columns that don't exist (or the empty mapping suggested from `[]`) is then stored silently and only fails later at commit time with a less clear error. Consider validating the mapping whenever the user explicitly provided one, instead of skipping on empty header sets.","suggestion_code":null,"existing_code":" for (const [field, header] of Object.entries(mapping)) {\n if (header && allowedHeaders.size > 0 && !allowedHeaders.has(header)) {"}
{"path":"apps/server/src/imports/imports.controller.ts","start_line":88,"end_line":89,"category":"bug","severity":"medium","content":"`Number('')` evaluates to 0, and `Number.isFinite(0)` is true. In a multipart form, if the client sends `conversationId` as an empty/blank field, a bogus `conversationId = 0` gets persisted on the import run instead of `undefined`. Guard against empty strings (and ideally trim) before coercing.","suggestion_code":" const conversationId =\n body.conversationId !== undefined && body.conversationId.trim() !== ''\n ? Number(body.conversationId)\n : undefined;","existing_code":" const conversationId =\n body.conversationId !== undefined ? Number(body.conversationId) : undefined;"}
{"path":"apps/server/src/imports/imports.controller.ts","start_line":58,"end_line":64,"category":"bug","severity":"medium","content":"`stages` is only checked with `Array.isArray` and then blindly cast to `ImportStageRequest[]`. A malformed element such as `{ sheets: 'abc' }` passes this cast but later throws a TypeError inside `expandStageSheets` (`stage.sheets.map` is not a function) or `worksheet.getRow` when `headerRow` is not a number, surfacing as an unhandled 500 instead of a user-facing 400. Validate each element (sheets/sheet must be strings, headerRow a positive integer, stepKey a known key) here before delegating to the service.","suggestion_code":null,"existing_code":" try {\n const parsed = JSON.parse(body.stages) as unknown;\n if (!Array.isArray(parsed)) throw new Error('not array');\n stages = parsed as ImportStageRequest[];\n } catch {\n throw new BadRequestException('stages 参数格式错误');\n }"}
{"path":"apps/server/src/imports/imports.controller.ts","start_line":66,"end_line":67,"category":"maintainability","severity":"low","content":"The JSON-parsing blocks for `stages`, `mapping`, and `settings` are near-duplicates; extract a small helper (parse optional JSON string + predicate) to avoid triplication. Also note the behavioral inconsistency: a non-array `stages` payload throws a 400, while a parsed non-object `mapping`/`settings` value is silently dropped (`mapping`/`settings` stay `undefined`), which can mask client-side mistakes. Make the failure behavior uniform.","suggestion_code":null,"existing_code":" let mapping: Partial<Record<ImportStepKey, Record<string, string>>> | undefined;\n if (typeof body.mapping === 'string' && body.mapping.trim()) {"}
{"path":"apps/server/src/imports/imports.controller.ts","start_line":48,"end_line":49,"category":"security","severity":"low","content":"The endpoint accepts any uploaded file up to 10 MB without type filtering; `file.mimetype` is entirely client-controlled, and downstream `detectWorkbookKind` keys parsing off the filename extension/MIME. Consider adding a `fileFilter` (extension or magic-byte check for `.xlsx`/`.csv`) to reject unsupported uploads at the boundary and avoid wasted parsing of arbitrary payloads.","suggestion_code":null,"existing_code":" @Post('runs')\n @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))"}
{"path":"apps/server/src/imports/imports.controller.ts","start_line":159,"end_line":161,"category":"other","severity":"low","content":"Using `@Res()` without `{ passthrough: true }` bypasses Nest's standard response pipeline, so global response interceptors/transformations (e.g. request logging or security-header middleware relying on the response) won't run for this endpoint, unlike the other routes in this controller. If such interceptors are expected globally, prefer `@Res({ passthrough: true })` and return the buffer, or explicitly set any missing headers here.","suggestion_code":null,"existing_code":" async report(\n @Req() req: AuthenticatedRequest,\n @Res() res: Response,"}
{"path":"apps/server/src/imports/imports.rows.ts","start_line":52,"end_line":56,"category":"bug","severity":"medium","content":"Gender alias bug: '男性'/'女性' are mapped to 'male'/'female' on the lines above, but the validation whitelist is checked against the raw `genderRaw`. Since '男性'/'女性' are not in `['male', 'female', '男', '女']`, rows using these supported aliases are incorrectly rejected with '性别只能是男/女'. Validate against the normalized `gender` value instead, e.g. `if (gender && !['male', 'female'].includes(gender)) errors.push('性别只能是男/女');`","suggestion_code":" if (genderRaw === '男' || genderRaw === '男性') gender = 'male';\n if (genderRaw === '女' || genderRaw === '女性') gender = 'female';\n if (gender && !['male', 'female'].includes(gender)) {\n errors.push('性别只能是男/女');\n }","existing_code":" if (genderRaw === '男' || genderRaw === '男性') gender = 'male';\n if (genderRaw === '女' || genderRaw === '女性') gender = 'female';\n if (genderRaw && !['male', 'female', '男', '女'].includes(genderRaw)) {\n errors.push('性别只能是男/女');\n }"}
{"path":"apps/server/src/imports/imports.rows.ts","start_line":188,"end_line":190,"category":"bug","severity":"low","content":"Data integrity gap: the transfer branch does not verify that `transferDate` is not earlier than the old occupancy's `checkInDate`. As written, `writeRow` will set `oldOccupancy.checkOutDate` to a date that can precede `checkInDate`, producing inconsistent occupancy records. Consider validating the date order when the old occupancy is found, e.g. `if (oldOccupancy && transferDate && transferDate < oldOccupancy.checkInDate) errors.push('换宿日期不能早于原宿舍入住日期');`","suggestion_code":null,"existing_code":" const transferDate = parseDateValue(fields.transferDate);\n if (!transferDate) errors.push('换宿日期格式不正确(应为 YYYY-MM-DD');\n normalized.transferDate = transferDate;"}
{"path":"apps/server/src/integration/config/integration-config.controller.ts","start_line":32,"end_line":34,"category":"security","severity":"medium","content":"Permission mismatch: saving configuration (which persists sensitive credentials such as appSecret) is guarded by `integration:trigger` (触发集成). Per the RBAC presets, `integration:trigger` semantically means \"trigger/run the integration\", so any role granted trigger capability (e.g. to run syncs) can also read and overwrite the third-party credentials. There is no dedicated `integration:edit`/`integration:write` permission in `rbac-presets.ts`. Recommend adding a write permission (e.g. `integration:edit`) and using it here so that configuration changes are only allowed for users explicitly authorized to modify config.","suggestion_code":" @Post()\n @RequirePermission('integration:edit')\n async saveConfig(@Body() body: SaveIntegrationConfigDto) {","existing_code":" @Post()\n @RequirePermission('integration:trigger')\n async saveConfig(@Body() body: SaveIntegrationConfigDto) {"}
{"path":"apps/server/src/integration/config/integration-config.controller.ts","start_line":40,"end_line":42,"category":"security","severity":"medium","content":"`testConnection` only requires the read-only `integration:read` permission, but it has side effects beyond reading: it performs a real outbound network call to the third-party (DingTalk) and pulls the stored raw appSecret from the DB (see `IntegrationConfigService.testConnection` -> `getRawConfig`) to authenticate. A role with only \"view integration status\" can therefore trigger outbound requests and cause the stored secret to be used externally. Consider guarding this endpoint with a write/trigger permission (`integration:trigger`) instead of a read-only one.","suggestion_code":" @Post('test')\n @RequirePermission('integration:trigger')\n async testConnection(@Body() body: TestIntegrationConfigDto) {","existing_code":" @Post('test')\n @RequirePermission('integration:read')\n async testConnection(@Body() body: TestIntegrationConfigDto) {"}
{"path":"apps/server/src/integration/config/integration-config.controller.ts","start_line":25,"end_line":27,"category":"other","severity":"low","content":"When the config for a type is not found, the endpoint returns HTTP 200 with `success: false` instead of a proper 404 status. This masks errors from API clients (they must inspect the body to detect failure) and is inconsistent with typical NestJS error semantics. Consider throwing `NotFoundException` (as `IntegrationConfigService.getAccessToken` already does) so the global exception filter produces a 404 response.","suggestion_code":" if (!data) {\n throw new NotFoundException(`未找到 ${type} 的配置`);\n }","existing_code":" if (!data) {\n return { success: false, message: `未找到 ${type} 的配置` };\n }"}
{"path":"apps/server/src/imports/imports.workbook.ts","start_line":9,"end_line":11,"category":"bug","severity":"medium","content":"Silent data truncation: only the first 30 sheets, first 3000 non-empty rows per sheet, and first 60 columns are extracted, yet the resulting `ImportSheetData[]` is returned as if it were the complete workbook. Any larger import will silently lose rows/columns with no warning to the user. Consider detecting when a limit is hit (e.g., `worksheet.rowCount > MAX_ROWS_PER_SHEET` or remaining sheets exist) and throwing a clear `BadRequestException`/warning so users know the upload was truncated.","suggestion_code":null,"existing_code":"const MAX_SHEETS = 30;\nconst MAX_ROWS_PER_SHEET = 3000;\nconst MAX_COLS_PER_SHEET = 60;"}
{"path":"apps/server/src/imports/imports.workbook.ts","start_line":56,"end_line":59,"category":"bug","severity":"medium","content":"MIME/extension classification has several misclassification paths: (1) any `text/plain` upload is accepted as CSV, so a random `.txt` file passes validation; (2) CSV is checked before XLSX, so an `.xlsx` file uploaded with a `text/csv`/`text/plain` MIME type is parsed as CSV and fails with a misleading error; (3) for `.xls` files uploaded as `application/octet-stream`, `detectWorkbookKind` returns `null` and the user gets the generic \"仅支持 .xlsx / .csv\" message instead of the more helpful `.xls` hint, because kind detection runs before the `.xls` check in `parseSheets`. Consider prioritizing the filename extension over MIME and handling `.xls` before kind detection.","suggestion_code":null,"existing_code":" const isCsv =\n /\\.csv$/i.test(originalName) ||\n /csv/i.test(mimeType) ||\n /text\\/(csv|plain)/i.test(mimeType);"}
{"path":"apps/server/src/imports/imports.workbook.ts","start_line":108,"end_line":108,"category":"maintainability","severity":"low","content":"The catch block discards the original error and always throws 'Excel 文件解析失败,请检查文件格式' — this is misleading for CSV failures (message says \"Excel 文件\") and hides the root cause, making debugging hard. Suggest logging the original error (e.g., via a logger) and using kind-specific messages ('CSV 文件解析失败' / 'Excel 文件解析失败').","suggestion_code":null,"existing_code":" throw new BadRequestException('Excel 文件解析失败,请检查文件格式');"}
{"path":"apps/server/src/imports/imports.workbook.ts","start_line":86,"end_line":86,"category":"performance","severity":"low","content":"`Buffer.from(buffer)` creates a redundant copy since `buffer` is already a `Buffer`. Pass the buffer directly: `Readable.from(buffer)`.","suggestion_code":null,"existing_code":" await workbook.csv.read(Readable.from(Buffer.from(buffer)));"}
{"path":"apps/server/src/imports/imports.workbook.ts","start_line":84,"end_line":84,"category":"security","severity":"medium","content":"No size guard is applied before ExcelJS fully loads/decompresses the workbook into memory (`.xlsx` is a zip archive). A very large file or a zip-bomb style payload can exhaust server memory during `workbook.xlsx.load` / `readXlsxSheetsFallback`. Consider rejecting files above a byte-size limit (e.g., checking `buffer.byteLength` up front) and, in the fallback, validating the zip entry count/sizes before reading them.","suggestion_code":null,"existing_code":" const workbook = new ExcelJS.Workbook();"}
{"path":"apps/server/src/integration/config/dto/config.dto.ts","start_line":54,"end_line":58,"category":"maintainability","severity":"medium","content":"The conditional `@Type` silently falls back to `WeComThirdConfigDto` for ANY non-DINGTALK value, including a missing/invalid `type` or any future member added to `IntegrationType`. If a third integration type is later added to the enum, its `config` will be silently transformed and validated against the WeCom schema instead of failing loudly. Prefer an explicit mapping over the implicit else-branch, e.g. a switch on the enum with an explicit default that throws/maps deliberately.","suggestion_code":" @Type((options) => {\n switch (options?.object?.type) {\n case IntegrationType.DINGTALK:\n return DingTalkThirdConfigDto;\n case IntegrationType.WECOM:\n return WeComThirdConfigDto;\n default:\n return WeComThirdConfigDto;\n }\n })","existing_code":" @Type((options) =>\n options?.object?.type === IntegrationType.DINGTALK\n ? DingTalkThirdConfigDto\n : WeComThirdConfigDto,\n )"}
{"path":"apps/server/src/integration/config/dto/config.dto.ts","start_line":34,"end_line":46,"category":"maintainability","severity":"low","content":"`WeComThirdConfigDto` is an exact subset of `DingTalkThirdConfigDto` (which only adds `appId`), so the two classes duplicate the same required fields and validators. Extract the common `agentId`/`appSecret`/`corpId` fields into a shared base DTO and let both integration DTOs extend it, keeping validation rules defined once.","suggestion_code":"class ThirdConfigBaseDto {\n @IsString()\n @IsNotEmpty()\n agentId: string;\n\n @IsOptional()\n @IsString()\n appSecret?: string;\n\n @IsString()\n @IsNotEmpty()\n corpId: string;\n}\n\nexport class WeComThirdConfigDto extends ThirdConfigBaseDto {}","existing_code":"export class WeComThirdConfigDto {\n @IsString()\n @IsNotEmpty()\n agentId: string;\n\n @IsOptional()\n @IsString()\n appSecret?: string;\n\n @IsString()\n @IsNotEmpty()\n corpId: string;\n}"}
{"path":"apps/server/src/integration/config/dto/config.dto.ts","start_line":67,"end_line":68,"category":"maintainability","severity":"low","content":"The `type` field of the returned config DTO is declared as a plain `string`, which weakens type safety even though `IntegrationType` (already defined in this file) is the canonical set of supported values. Type it as `IntegrationType` so callers get compile-time checks against the known integration types.","suggestion_code":"export interface ThirdConfigBaseDTO<T = unknown> {\n type: IntegrationType;","existing_code":"export interface ThirdConfigBaseDTO<T = unknown> {\n type: string;"}
{"path":"apps/server/src/integration/dingtalk.groups.ts","start_line":116,"end_line":116,"category":"bug","severity":"medium","content":"The `_opUserId` parameter is declared (with default 'manager') but never sent in the request body — the API `topapi/attendance/getsimplegroups` requires `op_user_id`, and every other request in this module sends it. This request will likely fail with a DingTalk parameter error, or the parameter is simply dead code. Send it in the body: `JSON.stringify({ op_user_id: _opUserId, offset, size: 10 })` (and drop the underscore prefix).","suggestion_code":" body: JSON.stringify({ op_user_id: opUserId, offset, size: 10 }),","existing_code":" body: JSON.stringify({ offset, size: 10 }),"}
{"path":"apps/server/src/integration/dingtalk.groups.ts","start_line":21,"end_line":23,"category":"bug","severity":"medium","content":"All `fetch` calls in this file lack `res.ok` checking and try/catch handling. If DingTalk returns a non-2xx status (or the response is not JSON, e.g. a gateway error page), `res.json()` will throw a raw `SyntaxError`/`TypeError`; network failures propagate as opaque errors instead of the user-friendly `ServiceUnavailableException` used elsewhere. There is also no timeout, so a hung connection blocks the request indefinitely. Recommend checking `res.ok` and wrapping in try/catch that rethrows a meaningful error (e.g. `ServiceUnavailableException`), and adding a timeout via `AbortSignal.timeout(...)`.","suggestion_code":null,"existing_code":" const res = await fetch(\n `https://oapi.dingtalk.com/topapi/attendance/group/add?access_token=${token}`,\n {"}
{"path":"apps/server/src/integration/dingtalk.groups.ts","start_line":37,"end_line":37,"category":"bug","severity":"low","content":"`data.result!.id` uses a non-null assertion: if DingTalk returns `errcode === 0` without a `result.id` (or `result` is absent), this throws a `TypeError` after the success log rather than failing gracefully. Guard the value explicitly, e.g. `const id = data.result?.id; if (!id) throw new Error('钉钉创建考勤组成功但未返回 id');`","suggestion_code":null,"existing_code":" return data.result!.id;"}
{"path":"apps/server/src/integration/dingtalk.groups.ts","start_line":48,"end_line":48,"category":"maintainability","severity":"low","content":"The DingTalk base URL `https://oapi.dingtalk.com` and the `access_token` query construction are duplicated across five requests, and `size: 10` / `offset += 10` are magic numbers that can drift apart. Extract a shared base-URL/token helper and a single page-size constant so endpoints and pagination stay consistent.","suggestion_code":null,"existing_code":" `https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`,"}
{"path":"apps/server/src/imports/imports.workbook-fallback.ts","start_line":109,"end_line":109,"category":"bug","severity":"high","content":"A self-closing empty cell (`<c r=\"B1\"/>`, emitted by several xlsx writers) is not matched because this regex requires a closing `</c>`. When a self-closing cell sits between populated cells, `matchAll` pairs `<c r=\"B1\"/>` with the *next* cell's `</c>`, so the following cell's value is parsed with the wrong column reference — data shifts left and trailing cells are lost. Handle the self-closing form explicitly (e.g. match `|<c\\b([^>]*)\\/>` as an empty-cell alternative and adjust group indexes), or split the row body on `<c\\b` and parse each segment.","suggestion_code":null,"existing_code":" for (const cellMatch of rowMatch[1].matchAll(/<c\\b([^>]*)\\/?>([\\s\\S]*?)<\\/c>/gs)) {"}
{"path":"apps/server/src/imports/imports.workbook-fallback.ts","start_line":106,"end_line":106,"category":"bug","severity":"medium","content":"Same class of bug as the cell regex: a self-closing empty row (`<row r=\"N\"/>`) is not matched because the pattern requires `</row>`. If such a row is followed by a populated row, the two tags are merged into a single match — the following row's cells get attributed to the wrong row and all subsequent `rowNumbers` shift. Also, the row's `r` attribute (real Excel row number) is ignored; it should be captured and carried through for accurate reporting.","suggestion_code":null,"existing_code":" for (const rowMatch of xml.matchAll(/<row\\b[^>]*>([\\s\\S]*?)<\\/row>/gs)) {"}
{"path":"apps/server/src/imports/imports.workbook-fallback.ts","start_line":118,"end_line":118,"category":"bug","severity":"medium","content":"When a `t=\"s\"` cell has no `<v>` child (or an empty one), `body.match(/<v>([^<]*)<\\/v>/)?.[1] ?? ''` yields `''`, and `Number('')` is `0` — an integer — so the cell is silently filled with `sharedStrings[0]`. Only convert to a number when a `<v>` value actually exists; otherwise keep `''` (e.g. check the `<v>` match before calling `Number`).","suggestion_code":null,"existing_code":" value = Number.isInteger(index) ? (sharedStrings[index] ?? '') : '';"}
{"path":"apps/server/src/imports/imports.workbook-fallback.ts","start_line":73,"end_line":73,"category":"bug","severity":"medium","content":"`rowNumbers` is derived from the array index (`i + 1`) rather than the actual Excel row number. Since `sheetRowsFromXmlFallback` drops rows without cells and this loop skips all-empty rows, the reported numbers drift from the real worksheet rows, so import error messages can point users at the wrong row. Preserve the `r=\"...\"` attribute from the row element during parsing and use it here.","suggestion_code":null,"existing_code":" rowNumbers.push(i + 1);"}
{"path":"apps/server/src/imports/imports.workbook-fallback.ts","start_line":156,"end_line":158,"category":"bug","severity":"medium","content":"`String.fromCodePoint` throws `RangeError` for code points above `0x10FFFF` or for lone surrogates (e.g. `&#x110000;`, `&#xD800;`). Cell values and sheet names come from user-uploaded files, so a crafted value can crash the whole import. Validate the parsed code point (same applies to the decimal `&#(...);` branch below) before converting, or wrap in try/catch.","suggestion_code":null,"existing_code":" .replace(/&#x([0-9a-fA-F]+);/g, (_all, hex: string) =>\n String.fromCodePoint(Number.parseInt(hex, 16)),\n )"}
{"path":"apps/server/src/imports/imports.workbook-fallback.ts","start_line":26,"end_line":27,"category":"maintainability","severity":"low","content":"The same prefix-stripping regex is re-inlined in `parseSharedStringsFallback` and `sheetRowsFromXmlFallback`. Hoist `stripPrefixes` to module scope and reuse it so all three call sites stay consistent.","suggestion_code":null,"existing_code":" const stripPrefixes = (value: string): string =>\n value.replace(/<(\\/?)([a-zA-Z][\\w.]*):/g, '<$1');"}
{"path":"apps/server/src/imports/imports.workbook-fallback.ts","start_line":41,"end_line":41,"category":"maintainability","severity":"low","content":"`.replace(/<sheet\\b/, '<sheet')` is a no-op (replaces the text with itself); `match[0]` is already the full `<sheet .../>` tag captured by the `<sheet\\b[^>]*\\/?>` pattern. Remove it.","suggestion_code":" const tag = match[0].replace(/\\/?>$/, '>');","existing_code":" const tag = match[0].replace(/<sheet\\b/, '<sheet').replace(/\\/?>$/, '>');"}
{"path":"apps/server/src/imports/imports.workbook-fallback.ts","start_line":31,"end_line":31,"category":"bug","severity":"low","content":"This regex requires `Id` to appear before `Target` and both within a single `[^>]*` run. If a generator emits `Target` before `Id` (attribute order is not guaranteed in OOXML) the relationship is silently missed and the corresponding sheet is skipped without any error. Parse `Id` and `Target` from each `<Relationship ...>` tag independently instead of assuming a fixed attribute order.","suggestion_code":null,"existing_code":" /<Relationship[^>]*\\bId=\"([^\"]+)\"[^>]*\\bTarget=\"([^\"]+)\"/g,"}
{"path":"apps/server/src/imports/imports.workbook-fallback.ts","start_line":50,"end_line":50,"category":"performance","severity":"low","content":"The MAX_SHEETS / MAX_ROWS_PER_SHEET caps are only enforced later in `fallbackSheetsToImportSheets`; here every sheet in the workbook is fully parsed into `string[][]` and all shared strings are loaded before any limit is applied. For very large or hostile workbooks this can consume a lot of server memory. Consider applying the caps during parsing (or documenting the trade-off).","suggestion_code":null,"existing_code":" rows: sheetRowsFromXmlFallback(sheetXml, sharedStrings),"}
{"path":"apps/server/src/integration/dingtalk.leave.ts","start_line":92,"end_line":93,"category":"bug","severity":"medium","content":"Timezone inconsistency in the fallback path: when `${normalized}+08:00` is unparseable, `new Date(normalized)` parses in the server's local timezone rather than UTC+8, contradicting the method's stated \"统一按东八区解析\" contract. If the runtime is outside UTC+8, beginTime/endTime/approvedAt will be off by hours. Prefer always appending the explicit offset and treating an unparseable value as an error instead of silently falling back to local time.","suggestion_code":" const date = new Date(`${normalized}+08:00`);\n if (Number.isNaN(date.getTime())) {\n throw new Error(`钉钉日期格式无法解析: ${value}`);\n }\n return date;","existing_code":" const date = new Date(`${normalized}+08:00`);\n return Number.isNaN(date.getTime()) ? new Date(normalized) : date;"}
{"path":"apps/server/src/integration/dingtalk.leave.ts","start_line":57,"end_line":58,"category":"bug","severity":"medium","content":"No error handling around fetch/JSON parsing: network failures throw raw errors, a non-JSON body (e.g., a 5xx HTML error page) makes `res.json()` throw an unhelpful exception, and the HTTP status is never checked before trusting the body (only the DingTalk `errcode` is inspected). Wrap the request in try/catch, verify `res.ok`, and rethrow a user-friendly message containing the actual status/errmsg.","suggestion_code":" if (!res.ok) {\n throw new Error(`钉钉接口请求失败: HTTP ${res.status}`);\n }\n const data = (await res.json()) as DingTalkGetUpdateDataResponse;\n if (data.errcode !== 0) {","existing_code":" const data = (await res.json()) as DingTalkGetUpdateDataResponse;\n if (data.errcode !== 0) {"}
{"path":"apps/server/src/integration/dingtalk.leave.ts","start_line":45,"end_line":46,"category":"performance","severity":"low","content":"The fetch call has no timeout (no AbortSignal). A hung DingTalk request will block the caller indefinitely and hold the rate-limit slot while waiting. Consider adding an abort timeout (e.g., AbortSignal.timeout) so upstream stalls fail fast.","suggestion_code":" await this.context.rateLimit();\n const res = await fetch(\n `https://oapi.dingtalk.com/topapi/attendance/getupdatedata?access_token=${token}`,\n {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n signal: AbortSignal.timeout(10_000),\n body: JSON.stringify({","existing_code":" await this.context.rateLimit();\n const res = await fetch("}
{"path":"apps/server/src/integration/dingtalk-student-sync.ts","start_line":186,"end_line":197,"category":"bug","severity":"medium","content":"Two new DingTalk users sharing the same mobile that matches no existing student both fall into `creatable` and are inserted as two separate Student rows with an identical phone. This creates duplicate student records (and can blow up if `phone` has a unique constraint). Deduplicate `creatable` by mobile (e.g., first one created, later ones reported as conflicts) or enforce uniqueness before saving.","suggestion_code":null,"existing_code":" const createdStudents = creatable.length\n ? await manager.save(\n Student,\n creatable.map((user) =>\n manager.create(Student, {\n name: user.name,\n phone: user.mobile,\n status: 'active',\n organizationId: host!.id,\n }),\n ),\n )"}
{"path":"apps/server/src/integration/dingtalk-student-sync.ts","start_line":169,"end_line":169,"category":"bug","severity":"medium","content":"This sync performs several independent writes (update students, insert bindings, insert students, insert bindings again). If any later write fails, earlier writes are already committed, leaving partial/inconsistent state (e.g., a new student without its DingTalk mapping, or an updated profile without a binding). Wrap the whole operation in a transaction — or, since a caller-supplied `EntityManager` may already be inside one, document that callers must pass a transactional manager and guarantee atomicity.","suggestion_code":null,"existing_code":" if (updates.length) await manager.save(Student, updates);"}
{"path":"apps/server/src/integration/dingtalk-student-sync.ts","start_line":55,"end_line":57,"category":"bug","severity":"medium","content":"The flow is check-then-insert without any DB-level protection: two concurrent sync calls for the same ding user (or same phone with no existing student) can both pass the \"no mapping / no match\" checks and create duplicate rows. Rely on a unique constraint on `StudentDingMapping.dingUserId` (and dedupe creation by mobile), or use an atomic upsert/insert-on-conflict and map constraint violations to conflicts.","suggestion_code":null,"existing_code":" const mappings = await manager.find(StudentDingMapping, {\n where: { dingUserId: In(dingUserIds) },\n });"}
{"path":"apps/server/src/integration/dingtalk-student-sync.ts","start_line":47,"end_line":47,"category":"bug","severity":"low","content":"Duplicate `dingUserId` entries in `inputs` are silently dropped (first occurrence wins) and never reported in `conflicts` or `skipped`. Callers have no way to detect that input data was discarded. Consider recording a conflict for duplicates so the caller can correct the source data.","suggestion_code":null,"existing_code":" if (!users.has(dingUserId)) users.set(dingUserId, { dingUserId, name, mobile });"}
{"path":"apps/server/src/integration/dingtalk-student-sync.ts","start_line":118,"end_line":118,"category":"bug","severity":"low","content":"Phone-based matching queries all students regardless of `status`. A student who has left (inactive/archived) can be auto-bound to a DingTalk account and even have its name/phone overwritten by the Ding profile. Filter matches to active students (and consider requiring the names to agree) before auto-binding.","suggestion_code":null,"existing_code":" const matches = studentsByPhone.get(user.mobile) ?? [];"}
{"path":"apps/server/src/integration/dingtalk-student-sync.ts","start_line":43,"end_line":46,"category":"bug","severity":"low","content":"Only a length check is performed on `mobile`; malformed values such as \"abc\" or \"1\" pass validation and are later used to match or create students. Add a phone-format validation (digits only, reasonable length) and push a conflict for invalid values.","suggestion_code":null,"existing_code":" if (mobile && mobile.length > 20) {\n conflicts.push({ dingUserId, name, reason: '手机号超过20个字符' });\n continue;\n }"}
{"path":"apps/server/src/integration/dingtalk-student-sync.ts","start_line":147,"end_line":147,"category":"other","severity":"low","content":"DB errors (e.g., constraint violations on save) propagate as raw exceptions without context; this async function performs multi-step persistence with no try/catch or user-friendly error mapping. Wrap the persistence steps and rethrow with a meaningful message (e.g., which ding user/student failed), or document that the caller is responsible for translating errors.","suggestion_code":null,"existing_code":" if (creatable.length && !host) throw new Error('尚未配置本机构');"}
{"path":"apps/server/src/integration/config/integration-config.service.ts","start_line":67,"end_line":71,"category":"style","severity":"low","content":"Nested ternary violates the project rule (nested ternary not allowed) and is hard to read. Since detail.type is always 'WECOM_SYNC'/'DINGTALK_SYNC' (or unknown), use a lookup map instead.","suggestion_code":" type:\n { WECOM_SYNC: 'WECOM', DINGTALK_SYNC: 'DINGTALK' }[detail.type] ??\n detail.type,","existing_code":" type: detail.type.includes('WECOM')\n ? 'WECOM'\n : detail.type.includes('DINGTALK')\n ? 'DINGTALK'\n : detail.type,"}
{"path":"apps/server/src/integration/config/integration-config.service.ts","start_line":213,"end_line":214,"category":"bug","severity":"medium","content":"Because getTokenForTest always returns null for WECOM, every saveConfig('WECOM') silently stores enable=false (and testConnection always returns false). The user is told the save succeeded even though the config is permanently disabled and any future getAccessToken('WECOM')/sync will fail. If WeCom verification is genuinely not implemented, saveConfig should reject the request with a clear error instead of silently persisting a disabled config; otherwise testConnection/saveConfig should not treat the missing implementation as a failed verification.","suggestion_code":" // 企微暂不实现,直接拒绝,避免静默保存为未启用状态\n throw new BadRequestException(`暂不支持 ${type} 平台的 token 验证`);","existing_code":" // 企微暂不实现,返回 null\n return null;"}
{"path":"apps/server/src/integration/config/integration-config.service.ts","start_line":42,"end_line":47,"category":"bug","severity":"medium","content":"ensureConfig is a non-atomic find-then-create with no unique constraint on IntegrationConfig.type (entity only indexes configId on the detail table). Concurrent requests (e.g. parallel saveConfig/getThirdConfig/setSyncStatus) can each see no row and insert duplicate 'THIRD' rows, making subsequent findOne nondeterministic. Also note that read-only methods (getThirdConfig/getRawConfig/getSyncStatus) mutate the DB by creating a row as a side effect of reading. Add a unique index on type and handle the duplicate-key case (or use an upsert).","suggestion_code":" let config = await this.configRepo.findOne({ where: { type: 'THIRD' } });\n if (!config) {\n config = this.configRepo.create({ type: 'THIRD', isSync: false });\n await this.configRepo.save(config).catch(() =>\n // 唯一约束冲突时重查,避免并发创建重复行(需为 type 加唯一索引)\n this.configRepo.findOne({ where: { type: 'THIRD' } }),\n );\n }\n return config;","existing_code":" let config = await this.configRepo.findOne({ where: { type: 'THIRD' } });\n if (!config) {\n config = this.configRepo.create({ type: 'THIRD', isSync: false });\n await this.configRepo.save(config);\n }\n return config;"}
{"path":"apps/server/src/integration/config/integration-config.service.ts","start_line":96,"end_line":103,"category":"bug","severity":"medium","content":"On update, if the old content exists but cannot be parsed (or contains no appSecret), the exception is swallowed and the config is saved without an appSecret — the token test then fails and the previously working config is silently disabled (enable=false). The '首次配置必须提供 AppSecret' guard does not cover this path. When the secret can't be retained or provided on an update, throw instead of silently degrading the config.","suggestion_code":" if (!finalConfig.appSecret) {\n let oldCfg: Record<string, unknown> = {};\n try {\n oldCfg = this.parseStoredConfig(existingDetail.content);\n } catch {\n oldCfg = {};\n }\n if (oldCfg.appSecret) {\n finalConfig.appSecret = oldCfg.appSecret;\n } else {\n throw new BadRequestException('更新配置时必须提供 AppSecret');\n }\n }","existing_code":" if (!finalConfig.appSecret) {\n try {\n const oldCfg = this.parseStoredConfig(existingDetail.content);\n if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret;\n } catch {\n // ignore\n }\n }"}
{"path":"apps/server/src/integration/config/integration-config.service.ts","start_line":109,"end_line":110,"category":"bug","severity":"medium","content":"saveConfig never reports verification failure: getTokenForTest swallows all errors (network timeout, wrong secret, etc.) and returns null, so the method completes successfully with enable=false and only a log line records the failure. The API consumer cannot distinguish 'saved and verified' from 'saved but disabled', and a transient DingTalk outage silently disables the platform config. Consider throwing (or returning a result object) when verified is false.","suggestion_code":" const token = await this.getTokenForTest(request.type, finalConfig);\n const verified = !!token;\n if (!verified) {\n throw new BadRequestException(`配置验证失败,未保存: ${request.type}`);\n }","existing_code":" const token = await this.getTokenForTest(request.type, finalConfig);\n const verified = !!token;"}
{"path":"apps/server/src/integration/config/integration-config.service.ts","start_line":112,"end_line":116,"category":"security","severity":"low","content":"appSecret is persisted in plaintext inside the content JSON column (the entity comment itself notes '加密/明文'). Any DB leak exposes credentials. At minimum, encrypt the appSecret field before persisting and decrypt in getRawConfig. Additionally, the `verify` field stored here duplicates detail.enable and is never read anywhere (parseStoredConfig only returns the config portion), so it is dead data — same for the top-level `type` field.","suggestion_code":" const content = JSON.stringify({\n config: finalConfig,\n });","existing_code":" const content = JSON.stringify({\n type: request.type,\n verify: verified,\n config: finalConfig,\n });"}
{"path":"apps/server/src/integration/dingtalk.attendance.ts","start_line":51,"end_line":51,"category":"bug","severity":"medium","content":"DingTalk 的 listRecord 接口支持 offset/limit 分页(单页最多 50 条)并在响应中返回 hasMore。当前实现既未传分页参数、也未处理 hasMore当考勤记录超过单页上限时会被静默截断导致数据缺失。建议循环拉取直到 hasMore 为 false或显式传入 limit 并校验返回条数)。","suggestion_code":null,"existing_code":" const records = data.recordresult ?? [];"}
{"path":"apps/server/src/integration/dingtalk.attendance.ts","start_line":60,"end_line":60,"category":"bug","severity":"medium","content":"时区处理不一致workDate 按 UTC+8 计算,而 actualCheckTime 用 toISOString() 输出的是 UTC 时间。例如 UTC+8 的 00:30 打卡会被表示为前一天 16:30Z与 workDate 无法对齐,下游按同一天关联打卡与考勤日期时会发生错位。建议统一用 dayjs(r.userCheckTime).utcOffset(8) 输出(如 dayjs.utc(r.userCheckTime).utcOffset(8).format(...))。","suggestion_code":null,"existing_code":" actualCheckTime: new Date(r.userCheckTime).toISOString(),"}
{"path":"apps/server/src/integration/dingtalk.attendance.ts","start_line":29,"end_line":30,"category":"bug","severity":"medium","content":"fetch 后未检查 res.ok/HTTP 状态码,也未对 res.json() 做保护网络错误、限流429、网关错误时 res.json() 可能抛异常或解析非 JSON 内容(如网关 HTML 页),原始异常(如 \"Unexpected token\")会直接冒泡给调用方,错误信息不可读。建议先检查 res.ok将响应按文本读取后容错解析 JSON统一包装为友好的业务错误同时为该请求设置超时如 AbortSignal.timeout。","suggestion_code":null,"existing_code":" const res = await fetch(\n `https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,"}
{"path":"apps/server/src/integration/dingtalk.attendance.ts","start_line":30,"end_line":30,"category":"security","severity":"low","content":"access_token 直接拼接在 URL query 中,会出现在网关/代理访问日志、浏览器历史等位置,存在凭据泄露风险(钉钉旧版 oapi 若只支持 query 传参,也建议确认日志脱敏策略)。同时该固定第三方接口地址硬编码在业务代码中,建议将 API 地址、超时等收敛为常量或配置项统一管理。","suggestion_code":null,"existing_code":" `https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,"}
{"path":"apps/server/src/integration/dingtalk.attendance.ts","start_line":20,"end_line":20,"category":"bug","severity":"low","content":"startDate/endDate 未做非空与格式校验:空字符串会拼成 \" 00:00:00\" 直接透传给钉钉,非法格式(非 YYYY-MM-DD也只会得到晦涩的远端错误endDate 早于 startDate 同样未校验。建议在请求前校验日期格式与先后关系,并给出明确的本地错误信息。","suggestion_code":null,"existing_code":" const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`;"}
{"path":"apps/server/src/integration/dingtalk.schedules.ts","start_line":52,"end_line":54,"category":"bug","severity":"medium","content":"方法注释声明“7天内最多50人”但未对 userIds 数量和日期跨度做任何前置校验。超过 50 人或日期跨度超过 7 天时,钉钉 API 会直接返回错误或截断数据,问题只能等到运行时才暴露,且错误信息不直观。建议在调用前显式校验并给出清晰报错。","suggestion_code":" if (userIds.length === 0 || userIds.length > 50) {\n throw new Error(`排班查询单次最多50人当前 ${userIds.length} 人`);\n }\n if (toDate - fromDate > 7 * 24 * 60 * 60 * 1000) {\n throw new Error('排班查询时间跨度不能超过7天');\n }\n","existing_code":" async queryScheduleByUsers(\n userIds: string[], fromDate: number, toDate: number, opUserId = 'manager',\n ): Promise<DingTalkScheduleResult[]> {"}
{"path":"apps/server/src/integration/dingtalk.schedules.ts","start_line":34,"end_line":36,"category":"bug","severity":"medium","content":"fetch 后直接 `res.json()` 并访问 `data.errcode`,缺少对 HTTP 状态码和网络异常的处理:非 2xx 响应(如 401/502返回 HTML/非 JSON 时 `res.json()` 会抛晦涩的 SyntaxError网络故障时 fetch 异常也会未加包装地向上抛。不符合异步错误处理规范,建议捕获异常并转换为对调用方友好的错误,同时校验 `res.ok`。queryScheduleByUsers 中的 fetch 存在同样问题。","suggestion_code":" let res: Response;\n try {\n res = await fetch(\n `https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`,\n {","existing_code":" const res = await fetch(\n `https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`,\n {"}
{"path":"apps/server/src/integration/dingtalk.schedules.ts","start_line":60,"end_line":60,"category":"maintainability","severity":"low","content":"钉钉 API 端点 URL 直接硬编码在业务代码中本文件两处scheduleUsers 与 queryScheduleByUsers不利于多环境/多租户配置与统一维护,也与调用逻辑耦合。建议将端点地址收敛到常量或配置(如环境变量/context 配置)中统一管理。","suggestion_code":null,"existing_code":" `https://oapi.dingtalk.com/topapi/attendance/schedule/listbyusers?access_token=${token}`,"}
{"path":"apps/server/src/integration/dingtalk.schedules.ts","start_line":55,"end_line":58,"category":"maintainability","severity":"low","content":"两个方法重复了同一套模板isConfigured 校验 → getAccessToken → rateLimit → fetch → 解析 errcode → 抛错。可抽取为私有辅助方法(如 `private async request(path, body)`统一处理令牌获取、限流、HTTP/errcode 错误转换,降低重复代码与后续不一致的风险。","suggestion_code":null,"existing_code":" if (!(await this.context.isConfigured())) throw new ServiceUnavailableException('钉钉未配置');\n const token = await this.context.getAccessToken();\n\n await this.context.rateLimit();"}
{"path":"apps/server/src/integration/dingtalk.service.ts","start_line":132,"end_line":138,"category":"bug","severity":"medium","content":"Concurrent token refresh race: the cache check + fetch + assignment in getAccessToken is not atomic. When multiple callers (syncAll, fetchOrgTree, sub-clients) invoke it concurrently right after expiry, each performs its own network token request, and a stale response can overwrite a newer token (last-write-wins). Cache the in-flight Promise so concurrent callers share a single refresh, e.g. `this.tokenPromise ??= this.doFetchToken(); const token = await this.tokenPromise; this.tokenPromise = null;`.","suggestion_code":null,"existing_code":" if (\n this.accessToken &&\n this.accessTokenCredentialKey === credentialKey &&\n Date.now() < this.tokenExpiresAt - 60_000\n ) {\n return this.accessToken;\n }"}
{"path":"apps/server/src/integration/dingtalk.service.ts","start_line":180,"end_line":180,"category":"maintainability","severity":"medium","content":"Hardcoded DingTalk API URLs: `https://oapi.dingtalk.com/topapi/v2/user/list`, `/department/listsub` and `/department/get` are inlined in this service, while the project convention centralizes third-party fixed endpoints in `integration/endpoints.ts` (with `aislop-ignore` annotations). This also hides the fact that OAuth uses `api.dingtalk.com` while these use `oapi.dingtalk.com`. Move these three URL templates to `endpoints.ts` (like DINGTALK_OAUTH_TOKEN_URL) and reference them here.","suggestion_code":null,"existing_code":" `https://oapi.dingtalk.com/topapi/v2/user/list?access_token=${token}`,"}
{"path":"apps/server/src/integration/dingtalk.service.ts","start_line":145,"end_line":145,"category":"bug","severity":"low","content":"getAccessToken does not check `res.ok` before parsing JSON. If DingTalk returns a non-200 response with a non-JSON body (e.g. 502 from a gateway, rate-limit HTML page), `res.json()` throws a raw SyntaxError that propagates as a generic 500 without a user-friendly message. Add a `if (!res.ok) throw new Error(...)` guard before parsing, consistent with getDeptUsers.","suggestion_code":" if (!res.ok) throw new Error(`钉钉 access_token 请求失败: HTTP ${res.status}`);\n const body: unknown = await res.json();","existing_code":" const body: unknown = await res.json();"}
{"path":"apps/server/src/integration/dingtalk.service.ts","start_line":368,"end_line":371,"category":"bug","severity":"medium","content":"rateLimit() does not actually enforce the documented \"每秒最多 20 次\" limit: it merely sleeps 50ms before each call, so parallel callers (e.g. buildDeptNode uses Promise.all over sub-departments, and multiple concurrent syncAll/fetchOrgTree runs) all sleep concurrently and then burst requests at up to N×20/s. Also the token request in getAccessToken bypasses rateLimit() entirely and is not counted in apiRequestCount, making the reported \"API 请求 N 次\" inaccurate. Consider a shared token-bucket / serialized queue shared across all requests.","suggestion_code":null,"existing_code":" async rateLimit(): Promise<void> {\n await sleep(DingTalkService.MIN_INTERVAL);\n this.apiRequestCount++;\n }"}
{"path":"apps/server/src/integration/dingtalk.service.ts","start_line":288,"end_line":288,"category":"bug","severity":"low","content":"getSubDepts/getDeptInfo only wrap HTTP status and errcode errors; a network-level failure (fetch rejection, DNS/timeout, `res.json()` parse error) propagates as a raw exception without the user-friendly ServiceUnavailableException wrapping used in getDeptUsers. Also the getDeptInfo failure message omits errcode/errmsg, making diagnosis harder. Wrap the fetch/parse in try/catch and include `errcode/errmsg` in the message.","suggestion_code":null,"existing_code":" if (!res.ok) throw new ServiceUnavailableException(`获取部门 ${deptId} 子部门失败: HTTP ${res.status}`);"}
{"path":"apps/server/src/integration/endpoints.ts","start_line":4,"end_line":4,"category":"maintainability","severity":"low","content":"The header states these endpoints \"should be overridden via each service's environment variables\" when pointing to a proxy/sandbox, but these are static exported constants with no `process.env` fallback, and consumers (e.g. `integration-config.service.ts` / `dingtalk.service.ts`) import them directly. The documented env-var override capability does not actually exist, which can mislead developers trying to switch to a proxy/sandbox. Either read from env vars with these values as defaults (e.g. `process.env.DINGTALK_OAUTH_TOKEN_URL ?? 'https://...'`) or correct the comment to reflect reality.","suggestion_code":null,"existing_code":" * 如需指向代理或沙箱环境,应通过各自服务的环境变量覆盖。"}
{"path":"apps/server/src/integration/dingtalk.types.ts","start_line":27,"end_line":29,"category":"bug","severity":"medium","content":"The type guard returns `true` whenever `errcode !== 0` even if `result` is missing/invalid, but `DingTalkUserListResponse` declares `result` as a required field. After this guard narrows a value, TypeScript will treat `result` (and `result.list`) as always present, so a caller doing `if (isDingTalkUserListResponse(data)) { data.result.list... }` can crash on undefined at runtime for error responses. Either return `false` here so only well-formed success payloads pass the guard, or make `result` optional in the interface and let callers check `errcode === 0` explicitly.","suggestion_code":" if (!('result' in value) || !value.result || typeof value.result !== 'object') {\n return false;\n }","existing_code":" if (!('result' in value) || !value.result || typeof value.result !== 'object') {\n return value.errcode !== 0;\n }"}
{"path":"apps/server/src/integration/dingtalk.types.ts","start_line":1,"end_line":1,"category":"maintainability","severity":"low","content":"This is a types-only file, and `Logger` is only used as a type annotation in `DingTalkServiceContext`. A value import from `@nestjs/common` adds a runtime dependency (and potential circular-import/coupling risk) for any consumer of this file. Use a type-only import instead.","suggestion_code":"import type { Logger } from '@nestjs/common';","existing_code":"import { Logger } from '@nestjs/common';"}
{"path":"apps/server/src/integration/dingtalk.types.ts","start_line":103,"end_line":105,"category":"maintainability","severity":"low","content":"`OrgDeptNodeWithUsers` extends `OrgDeptNode` without overriding `children`, so its `children` are typed as `OrgDeptNode[]` and the `users` data on descendant nodes is lost from the type system. If this tree is meant to carry user info at every level, override `children` with the users-bearing node type; otherwise document that only the root level carries `users`.","suggestion_code":"export interface OrgDeptNodeWithUsers extends OrgDeptNode {\n users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;\n children: OrgDeptNodeWithUsers[];\n}","existing_code":"export interface OrgDeptNodeWithUsers extends OrgDeptNode {\n users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;\n}"}
{"path":"apps/server/src/integration/dingtalk.shifts.ts","start_line":58,"end_line":59,"category":"bug","severity":"medium","content":"`data.result!.id` uses a non-null assertion that will throw a raw `TypeError` if DingTalk returns `errcode: 0` without a `result` payload (which can happen with a successful-but-empty response). The success log even uses optional chaining (`data.result?.name`), acknowledging the field may be absent. Guard the result and throw a descriptive error instead, e.g.: `if (!data.result?.id) throw new Error('钉钉班次操作成功但未返回班次ID')` before returning.","suggestion_code":null,"existing_code":" this.context.logger.log(`钉钉班次 ${params.id ? '更新' : '创建'} 成功: ${data.result?.name} (id=${data.result?.id})`);\n return data.result!.id;"}
{"path":"apps/server/src/integration/dingtalk.shifts.ts","start_line":33,"end_line":35,"category":"bug","severity":"medium","content":"When `params.id` is provided (update path), passing a partial `setting` object silently overwrites the omitted fields on the DingTalk side: e.g. updating with `{ is_flexible: true }` sends `serious_late_minutes: -1` and `absenteeism_late_minutes: -1`, wiping previously configured late/absenteeism thresholds. Only include the fields actually provided (spread `...params.setting`) or fetch-and-merge the existing setting before update to avoid destructive partial updates.","suggestion_code":null,"existing_code":" is_flexible: params.setting.is_flexible ?? false,\n serious_late_minutes: params.setting.serious_late_minutes ?? -1,\n absenteeism_late_minutes: params.setting.absenteeism_late_minutes ?? -1,"}
{"path":"apps/server/src/integration/dingtalk.shifts.ts","start_line":17,"end_line":17,"category":"bug","severity":"medium","content":"Hardcoded fallback `op_user_id: params.owner || 'manager'` is inconsistent: if `owner` is missing, the request is sent with `op_user_id: 'manager'` while `shift.owner` remains `undefined` (JSON key dropped). DingTalk requires a real, permission-bearing userid for both fields, so this silent fallback masks the missing owner and will fail on the API side with an unhelpful error. Validate that `owner` is provided (throw a clear error) instead of defaulting to a magic 'manager' string.","suggestion_code":null,"existing_code":" op_user_id: params.owner || 'manager',"}
{"path":"apps/server/src/integration/dingtalk.shifts.ts","start_line":51,"end_line":54,"category":"other","severity":"low","content":"`res.json()` is called without checking `res.ok`/HTTP status and without a fetch timeout. If the gateway returns a non-JSON body (e.g. a 502/HTML error page), `res.json()` throws a `SyntaxError` that masks the real cause, and HTTP-level failures (401/429/500) are never surfaced distinctly. Also a hung upstream request blocks indefinitely with no `AbortController` timeout. Consider checking `res.ok` and parsing defensively, and adding a timeout.","suggestion_code":null,"existing_code":" const data = (await res.json()) as {\n errcode: number; errmsg: string;\n result?: { id: number; name: string };\n };"}
{"path":"apps/server/src/integration/entities/integration-config.entity.ts","start_line":66,"end_line":67,"category":"security","severity":"high","content":"Security: this column stores sensitive third-party credentials (corpId/appSecret, possibly in plaintext per the class comment). Storing secrets in plaintext in the DB and exposing `content` directly in API responses/logs risks credential leakage. Recommend encrypting sensitive fields at rest, masking them in responses/logs, and ensuring `content` is never returned to clients without sanitization.","suggestion_code":null,"existing_code":" @Column({ type: 'text', nullable: true })\n content: string;"}
{"path":"apps/server/src/integration/entities/integration-config.entity.ts","start_line":16,"end_line":17,"category":"bug","severity":"medium","content":"The comment states the main table is a global singleton ('全局单例'), but there is no unique constraint or application-level guard enforcing a single row. Duplicate rows would silently break the singleton assumption. Consider adding a unique constraint on `type` (or a dedicated singleton mechanism) and/or validation at the service layer.","suggestion_code":null,"existing_code":"@Entity('integration_config')\nexport class IntegrationConfig {"}
{"path":"apps/server/src/integration/entities/integration-config.entity.ts","start_line":26,"end_line":27,"category":"maintainability","severity":"low","content":"Business constants ('THIRD', 'WECOM', 'DINGTALK', 'DINGTALK_SYNC', 'WECOM_SYNC') are only documented in comments as free-form strings. Since these values are likely referenced in multiple places (service/controller logic), typos or drift are easy. Recommend defining them as TypeScript enums/union types/const objects and using them in column definitions (e.g., `type: enum`) to keep the schema and runtime values in sync.","suggestion_code":null,"existing_code":" @Column({ name: 'sync_resource', length: 50, nullable: true })\n syncResource: string;"}
{"path":"apps/server/src/integration/entities/integration-config.entity.ts","start_line":66,"end_line":67,"category":"maintainability","severity":"low","content":"`content` is an unvalidated JSON string with an implicit schema ({ type, verify, config }). Invalid/legacy payloads can pass through unnoticed and cause runtime parse errors. Consider using TypeORM's `json` column type (if the DB supports it) plus schema validation (class-validator) at write time.","suggestion_code":null,"existing_code":" @Column({ type: 'text', nullable: true })\n content: string;"}
{"path":"apps/server/src/main.ts","start_line":12,"end_line":12,"category":"security","severity":"medium","content":"`app.enableCors()` is called without any options, which opens the API to every origin (Access-Control-Allow-Origin: *). If this API handles authenticated requests or sensitive data, malicious websites can issue cross-origin requests and read responses. Configure an explicit allowlist (origins/methods/headers), e.g. `app.enableCors({ origin: [...allowedOrigins] })`, or disable CORS in production.","suggestion_code":null,"existing_code":" app.enableCors();"}
{"path":"apps/server/src/main.ts","start_line":19,"end_line":19,"category":"maintainability","severity":"medium","content":"`bootstrap()` is fired with `void` but no error handling: if `runMigrationsOnStartup()` or `app.listen()` fails, the resulting unhandled promise rejection gives no clear startup error. Add a `.catch()` that logs a user-friendly message and calls `process.exit(1)`, and consider `app.enableShutdownHooks()` so connections are closed gracefully on shutdown.","suggestion_code":null,"existing_code":"void bootstrap();"}
{"path":"apps/server/src/migration-runner.ts","start_line":43,"end_line":45,"category":"bug","severity":"high","content":"If `ds.initialize()` or `ds.runMigrations()` throws, `ds.destroy()` is never called, leaking the MySQL connection and potentially leaving the process hanging. Wrap the migration steps in try/catch/finally so the data source is always destroyed (guarding with `ds.isInitialized`), and rethrow/log the error so the caller can fail fast.","suggestion_code":" try {\n await ds.initialize();\n await ds.runMigrations();\n } catch (err) {\n console.error('Failed to run database migrations on startup', err);\n throw err;\n } finally {\n if (ds.isInitialized) {\n await ds.destroy();\n }\n }","existing_code":" await ds.initialize();\n await ds.runMigrations();\n await ds.destroy();"}
{"path":"apps/server/src/integration/wecom.service.ts","start_line":58,"end_line":59,"category":"bug","severity":"medium","content":"`fetch()` does not reject on non-2xx HTTP status. If WeCom returns 429/5xx or a non-JSON body (rate limit, gateway error), `res.json()` throws a raw parse error or the errcode check fails with a confusing message. Check `res.ok` before parsing in all three fetch methods (`getAccessToken`, `fetchDepartments`, `fetchUsers`) and throw a descriptive error.","suggestion_code":null,"existing_code":" const res = await fetch(url);\n const body = (await res.json()) as WeComTokenResponse;"}
{"path":"apps/server/src/integration/wecom.service.ts","start_line":82,"end_line":85,"category":"bug","severity":"low","content":"This recursive traversal has no cycle detection. Since the API returns the queried department plus its direct children and the code recurses into every child, a malformed `parentid` loop in WeCom data would cause unbounded recursion and a stack overflow. Guard the traversal with a `visited` Set of department ids.","suggestion_code":null,"existing_code":" if (dept.id !== parentId) {\n const children = await this.fetchDepartments(token, dept.id);\n all.push(...children);\n }"}
{"path":"apps/server/src/integration/wecom.service.ts","start_line":113,"end_line":114,"category":"performance","severity":"medium","content":"`fetchUsers` calls are awaited sequentially inside `for...of` even though they are independent; this serializes many network round trips. Moreover, since `fetch_child=1` already returns descendant users, iterating over every department re-fetches the same users repeatedly. Fetch user lists in parallel with `Promise.all` (or only fetch from top-level departments) and keep the `seenUserIds` dedup to cut API calls dramatically.","suggestion_code":null,"existing_code":" for (const wd of wxDepts) {\n const wxUsers = await this.fetchUsers(token, wd.id);"}
{"path":"apps/server/src/integration/wecom.service.ts","start_line":118,"end_line":118,"category":"performance","severity":"medium","content":"Per-user `findOne` + `save` inside the loop causes N+1 database round trips, which is slow for large organizations (thousands of users). Batch the lookups (e.g., a single `find` for all `userid`s) and use an upsert keyed on the `username` unique constraint (e.g., TypeORM `queryBuilder` with `orUpdate`/`orIgnore`) to reduce DB calls to a couple of statements. Also avoid re-saving existing users whose name did not change.","suggestion_code":null,"existing_code":" let user = await this.userRepo.findOne({ where: { username: wu.userid } });"}
{"path":"apps/server/src/integration/wecom.service.ts","start_line":103,"end_line":108,"category":"maintainability","severity":"medium","content":"`syncAll` runs several external calls and DB writes with no try/catch; a mid-sync failure (network error, WeCom errcode, DB failure) leaves a partially synced user table and propagates a raw error to the caller. Wrap the sync body in try/catch, log a structured error (including WeCom errcode/errmsg), and make the operation idempotent so a retry completes the sync.","suggestion_code":null,"existing_code":" async syncAll(): Promise<{ userCount: number }> {\n if (!this.configured) {\n this.logger.warn('WeCom not configured (WECOM_CORP_ID / WECOM_CORP_SECRET missing), skipping sync');\n return { userCount: 0 };\n }\n const token = await this.getAccessToken();"}
{"path":"apps/server/src/integration/jinshuju-student-sync.ts","start_line":55,"end_line":55,"category":"maintainability","severity":"medium","content":"Dead code: `conflicts` is declared (and promised by the `JinshujuStudentSyncResult` interface) but never populated — every call returns an empty array. The parsed `serialNumber` field is likewise stored but never read afterwards. Either implement the intended conflict detection (see the name-match ambiguity note below) and fill this array, or remove the field from the result type to avoid a misleading API contract.","suggestion_code":null,"existing_code":"const conflicts: JinshujuStudentSyncResult['conflicts'] = [];"}
{"path":"apps/server/src/integration/jinshuju-student-sync.ts","start_line":95,"end_line":95,"category":"bug","severity":"medium","content":"`toCreate` is not de-duplicated: if two form entries have the same name+phone (e.g., the same person submits twice, or two people share a name) and that phone is not already in the DB, this loop pushes both into `toCreate` and two identical `Student` rows are created. Consider de-duplicating by phone first, then by name+phone, before creating.","suggestion_code":null,"existing_code":" toCreate.push({ name: p.name, phone: p.phone });"}
{"path":"apps/server/src/integration/jinshuju-student-sync.ts","start_line":83,"end_line":84,"category":"bug","severity":"medium","content":"Name matching does not verify phone consistency: an entry carrying a brand-new/unknown phone whose name happens to match an existing student is silently counted as \"matched\" to that student and its phone value is discarded. Two distinct people sharing a name can therefore be merged into one record. Suggest only accepting a name match when the candidate's stored phone is empty or equals the entry's phone; otherwise report it as a conflict (which would also make the currently-always-empty `conflicts` array meaningful).","suggestion_code":null,"existing_code":" const candidates = studentByName.get(p.name);\n if (candidates && candidates.length > 0) {"}
{"path":"apps/server/src/integration/jinshuju-student-sync.ts","start_line":36,"end_line":36,"category":"maintainability","severity":"low","content":"Business logic hardcoding: the form field mapping (`field_1` = name, `field_2` = phone) and the host-organization lookup (`isHost: true, status: 'active'`) are hardcoded. The mapping is acknowledged in the doc comment, but both should be configurable/parameterized (e.g., constants or an options argument) rather than buried in the sync logic.","suggestion_code":null,"existing_code":" const name = typeof entry.field_1 === 'string' ? entry.field_1.trim() : '';"}
{"path":"apps/server/src/integration/jinshuju.service.ts","start_line":79,"end_line":79,"category":"bug","severity":"medium","content":"Per the declared type `next: number | null`, 0 is a valid numeric cursor value, but `if (next)` and `while (next)` treat 0 as falsy, silently stopping pagination and losing data if the API ever returns 0 as a cursor. Since `body.next ?? undefined` already normalizes null → undefined, use explicit checks instead.","suggestion_code":" if (next !== undefined) url.searchParams.set('next', String(next));","existing_code":" if (next) url.searchParams.set('next', String(next));"}
{"path":"apps/server/src/integration/jinshuju.service.ts","start_line":95,"end_line":95,"category":"bug","severity":"medium","content":"If the API returns a malformed response without a `data` field (or null), `entries.push(...body.data)` throws a TypeError and aborts the whole pull. Add a defensive null check before spreading.","suggestion_code":" entries.push(...(body.data ?? []));","existing_code":" entries.push(...body.data);"}
{"path":"apps/server/src/integration/jinshuju.service.ts","start_line":64,"end_line":64,"category":"bug","severity":"low","content":"`field` may not be an object at runtime (e.g., null or a primitive). Accessing `field.label` directly would then throw and break the entire form-structure fetch. Guard the access (e.g., `field && typeof field.label === 'string' ...`) so a single malformed field cannot fail the whole request.","suggestion_code":" label: field && typeof field.label === 'string' && field.label.trim() ? field.label.trim() : key,","existing_code":" label: typeof field.label === 'string' && field.label.trim() ? field.label.trim() : key,"}
{"path":"apps/server/src/integration/jinshuju.service.ts","start_line":81,"end_line":81,"category":"maintainability","severity":"low","content":"The redaction regex `/api_key=[^&]+/` is dead code — the URL never contains an `api_key` query parameter (auth is sent via the `Authorization` header), so the replace never matches. Meanwhile the log still prints the full URL including the formToken path segment. Either drop the useless regex or actually redact the formToken.","suggestion_code":" this.logger.log(`Fetching Jinshuju entries: ${url.toString()}`);","existing_code":" this.logger.log(`Fetching Jinshuju entries: ${url.toString().replace(/api_key=[^&]+/, 'api_key=***')}`);"}
{"path":"apps/server/src/migrations/1784700000000-AddJinshujuMatchRules.ts","start_line":22,"end_line":22,"category":"bug","severity":"medium","content":"The `updated_at` column only has a `CURRENT_TIMESTAMP` default, which is applied at INSERT time. Without an `onUpdate` clause, MySQL will not automatically refresh `updated_at` when a row is modified, so the timestamp will go stale unless every update in the application code explicitly sets it. Add `onUpdate: 'CURRENT_TIMESTAMP'` so the database maintains the column, or ensure all update statements set it manually.","suggestion_code":"{ name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' },","existing_code":"{ name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' },"}
{"path":"apps/server/src/migrations/1784700000000-AddJinshujuMatchRules.ts","start_line":20,"end_line":20,"category":"maintainability","severity":"low","content":"`mappings` is stored as a free-form `text` column with no structural guarantee. If it holds serialized JSON (field-mapping configuration), consider using a `json` column type (supported by TypeORM on MySQL/Postgres) or add application-level validation/versioning for the payload so malformed or incompatible data cannot be persisted.","suggestion_code":null,"existing_code":"{ name: 'mappings', type: 'text' },"}
{"path":"apps/server/src/migrations/1784520727860-InitialSchema.ts","start_line":227,"end_line":228,"category":"bug","severity":"high","content":"The down() migration drops the same index `IDX_050485162d4fe47bd3cfd7dedb` on `room_expenses` twice in a row. The first statement succeeds and the second fails with MySQL error 1091 (index does not exist), aborting the entire rollback and leaving the database in a partially migrated state. Remove the duplicated statement.","suggestion_code":" await queryRunner.query(`DROP INDEX \\`IDX_050485162d4fe47bd3cfd7dedb\\` ON \\`room_expenses\\``);","existing_code":" await queryRunner.query(`DROP INDEX \\`IDX_050485162d4fe47bd3cfd7dedb\\` ON \\`room_expenses\\``);\n await queryRunner.query(`DROP INDEX \\`IDX_050485162d4fe47bd3cfd7dedb\\` ON \\`room_expenses\\``);"}
{"path":"apps/server/src/migrations/1784520727860-InitialSchema.ts","start_line":7,"end_line":7,"category":"bug","severity":"medium","content":"wallet_transactions references students (student_id NOT NULL), bills (bill_id) and users (recorded_by), but no foreign key constraints are defined for these columns — unlike the rest of the schema (e.g., personal_expenses has an FK to students, bills has an FK to students). This permits orphaned transaction rows and dangling bill references, and is inconsistent with the integrity model used everywhere else. Add the corresponding FKs, or if they are intentionally omitted for the financial ledger, document that decision and enforce integrity in the application layer.","suggestion_code":null,"existing_code":" await queryRunner.query(`CREATE TABLE \\`wallet_transactions\\` (\\`id\\` int NOT NULL AUTO_INCREMENT, \\`student_id\\` int NOT NULL, \\`bill_id\\` int NULL, \\`operation_id\\` varchar(64) NULL, \\`type\\` varchar(30) NOT NULL, \\`amount\\` decimal(12,2) NOT NULL, \\`balance_after\\` decimal(12,2) NOT NULL, \\`description\\` varchar(300) NULL, \\`recorded_by\\` int NULL, \\`created_at\\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), INDEX \\`IDX_680bbd0275ac5e06c179f9b84c\\` (\\`student_id\\`, \\`created_at\\`), PRIMARY KEY (\\`id\\`)) ENGINE=InnoDB`);"}
{"path":"apps/server/src/migrations/1784520727860-InitialSchema.ts","start_line":21,"end_line":21,"category":"performance","severity":"low","content":"High-traffic FK columns such as bills.student_id, occupancies.student_id, deposits.student_id and notifications.recipient_id are left unindexed (the composite index on wallet_transactions shows awareness of this pattern). As these tables grow, lookups/filters by student will degrade to full scans. Consider adding indexes on FK columns that are commonly used in WHERE/JOIN clauses.","suggestion_code":null,"existing_code":" await queryRunner.query(`CREATE TABLE \\`bills\\` (\\`id\\` int NOT NULL AUTO_INCREMENT, \\`student_id\\` int NOT NULL, \\`period_start\\` date NOT NULL, \\`period_end\\` date NOT NULL, \\`shared_amount\\` decimal(10,2) NOT NULL DEFAULT '0.00', \\`personal_amount\\` decimal(10,2) NOT NULL DEFAULT '0.00', \\`total_amount\\` decimal(10,2) NOT NULL DEFAULT '0.00', \\`source\\` varchar(30) NOT NULL DEFAULT 'batch', \\`paid_amount\\` decimal(10,2) NOT NULL DEFAULT '0.00', \\`outstanding_amount\\` decimal(10,2) NOT NULL DEFAULT '0.00', \\`status\\` varchar(20) NOT NULL DEFAULT 'unpaid', \\`cancelled_at\\` datetime NULL, \\`cancel_reason\\` varchar(300) NULL, \\`generated_at\\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\\`id\\`)) ENGINE=InnoDB`);"}
{"path":"apps/server/src/migrations/1784870000000-AddA2UiForms.ts","start_line":9,"end_line":9,"category":"bug","severity":"medium","content":"The early-return `if (hasTable('ai_forms')) return;` is unsafe for failure recovery. On MySQL, DDL is non-transactional (implicit commit), so if a previous run succeeded in `createTable` but failed while creating the FK (e.g., transient error or missing `ai_messages`), the table remains while the migration is marked failed. On re-run, `hasTable` returns true and the method returns early, silently leaving the FK missing forever — the schema then diverges from the `AiForm` entity's `@ManyToOne(..., { onDelete: 'CASCADE' })`, allowing orphaned rows. Suggest removing the early return and guarding each step idempotently instead, e.g. create the table only `if (!hasTable)`, and create the FK only if `getTable('ai_forms')` shows the FK is absent.","suggestion_code":"const tableExists = await queryRunner.hasTable('ai_forms');\n if (!tableExists) {\n await queryRunner.createTable(\n ...\n );\n }","existing_code":"if (await queryRunner.hasTable('ai_forms')) return;"}
{"path":"apps/server/src/migrations/1784870000000-AddA2UiForms.ts","start_line":36,"end_line":39,"category":"maintainability","severity":"low","content":"`createForeignKey` assumes the referenced `ai_messages` table already exists, but there is no existence guard. This works only because migration timestamps happen to order it after `1784780000000-AddAiChat.ts`; in any other scenario (fresh DB with truncated history, `ai_messages` dropped/recreated, or a failed prior partial run) the migration aborts with an opaque DB-level error, and per the early-return above it can never recover. Add a `hasTable('ai_messages')` check (and a comment/log) so the failure is explicit and diagnosable, or create the FK conditionally after inspecting the table's existing foreign keys.","suggestion_code":null,"existing_code":"await queryRunner.createForeignKey(\n 'ai_forms',\n new TableForeignKey({\n name: 'fk_ai_forms_assistant_message',"}
{"path":"apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts","start_line":15,"end_line":15,"category":"bug","severity":"medium","content":"`MODIFY` in MySQL replaces the entire column definition, and unspecified attributes fall back to defaults. The original column was created without `isNullable` (TypeORM's `Table` defaults to `false`), so it is `NOT NULL`; after this ALTER it silently becomes nullable, weakening the existing data-integrity constraint. Preserve the current nullability (and any other attributes) when enlarging the type.","suggestion_code":" await queryRunner.query(\n `ALTER TABLE ai_reviews MODIFY sections_json LONGTEXT ${column.isNullable ? 'NULL' : 'NOT NULL'}`,\n );","existing_code":" await queryRunner.query('ALTER TABLE ai_reviews MODIFY sections_json LONGTEXT');"}
{"path":"apps/server/src/migrations/1784900000000-EnlargeAiReviewSections.ts","start_line":12,"end_line":12,"category":"bug","severity":"medium","content":"The guard only checks the table and the column type, but not whether the `sections_json` column actually exists. If the column is missing (e.g., schema drift or a partially applied earlier migration), `column` is `undefined`, `columnType` becomes `''`, and the subsequent `ALTER TABLE ... MODIFY sections_json` fails with an obscure \"Unknown column\" error that blocks the whole migration chain. Add an early return when the column is not found, consistent with the table-existence guard above.","suggestion_code":" const column = table?.columns.find((item) => item.name === 'sections_json');\n if (!column) return;","existing_code":" const column = table?.columns.find((item) => item.name === 'sections_json');"}
{"path":"apps/server/src/migrations/1784680000000-AddRoomInspections.ts","start_line":17,"end_line":18,"category":"performance","severity":"low","content":"The `room_inspections.room_id` column has no dedicated index: the only index on the table is the composite unique index `uq_room_inspections_date_room (inspection_date, room_id)`, whose leading column is `inspection_date`. InnoDB requires an index on the FK column(s) as the leading columns to enforce `fk_room_inspections_room`, so MySQL will silently auto-create an implicit index on `room_id` (named after the constraint). This implicit index is invisible in the migration, varies across environments/tools, and is easy to miss during maintenance. Add an explicit `INDEX idx_room_inspections_room_id (room_id)` in the CREATE TABLE (or before the ALTER TABLE) so the FK support and room-based lookups are explicit and predictable.","suggestion_code":" UNIQUE INDEX \\`uq_room_inspections_date_room\\` (\\`inspection_date\\`, \\`room_id\\`),\n INDEX \\`idx_room_inspections_room_id\\` (\\`room_id\\`),\n INDEX \\`idx_room_inspections_inspector_id\\` (\\`inspector_id\\`),","existing_code":" UNIQUE INDEX \\`uq_room_inspections_date_room\\` (\\`inspection_date\\`, \\`room_id\\`),\n INDEX \\`idx_room_inspections_inspector_id\\` (\\`inspector_id\\`),"}
{"path":"apps/server/src/migrations/1784780000000-AddAiChat.ts","start_line":15,"end_line":16,"category":"bug","severity":"medium","content":"`updated_at` is defined with only `default: 'CURRENT_TIMESTAMP'` and no `onUpdate`. The codebase's own `InitialSchema` migration uses `ON UPDATE CURRENT_TIMESTAMP(6)` for `users.updated_at`. Without `ON UPDATE`, any direct SQL update of the row (e.g., refreshing `last_message_at`, bulk status updates) will leave `updated_at` stale — it stays at the insert time. Even though the entities use `@UpdateDateColumn` (app-managed), the DB-level convention in this project is `ON UPDATE`, so add it for consistency and to keep the timestamp accurate for non-TypeORM writes.","suggestion_code":"{ name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' },\n{ name: 'last_message_at', type: 'datetime', isNullable: true },","existing_code":"{ name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' },\n{ name: 'last_message_at', type: 'datetime', isNullable: true },"}
{"path":"apps/server/src/migrations/1784780000000-AddAiChat.ts","start_line":44,"end_line":44,"category":"bug","severity":"medium","content":"`created_at` is declared as plain `datetime` (second precision), but this table's primary query path is the `(conversation_id, created_at)` index used for ordering/pagination. Multiple messages written within the same second will tie on `created_at`, which can produce unstable ordering or duplicate/skipped rows in cursor-based pagination. The project's `InitialSchema` already uses `datetime(6)` + `CURRENT_TIMESTAMP(6)`. Prefer `datetime(6)`/`CURRENT_TIMESTAMP(6)` here (and for the other timestamp columns), or explicitly order by `id` as a tiebreaker.","suggestion_code":"{ name: 'idx_ai_messages_conversation_created', columnNames: ['conversation_id', 'created_at', 'id'] },","existing_code":"{ name: 'idx_ai_messages_conversation_created', columnNames: ['conversation_id', 'created_at'] },"}
{"path":"apps/server/src/migrations/1784780000000-AddAiChat.ts","start_line":80,"end_line":80,"category":"maintainability","severity":"low","content":"Asymmetric ownership: `up()` only creates tables that are missing (`if (!(await queryRunner.hasTable(...)))`), but `down()` unconditionally drops all three tables. If `ai_conversations`/`ai_messages`/`ai_tool_runs` already existed before this migration ran (e.g., created ad hoc or by an earlier partial deployment), running `down()` would drop tables this migration never created, destroying data. Mirror the same `hasTable` guard in `down()`, or remove the guard in `up()` so ownership is unambiguous.","suggestion_code":null,"existing_code":"for (const table of ['ai_tool_runs', 'ai_messages', 'ai_conversations']) {"}
{"path":"apps/server/src/migrations/1784780000000-AddAiChat.ts","start_line":64,"end_line":64,"category":"maintainability","severity":"low","content":"`ai_tool_runs.status` is NOT NULL with no default, which is inconsistent with `ai_messages.status` (default `'completed'`). If a tool-run row is ever inserted before the final status is known, the insert will fail with a NOT NULL constraint error. Consider providing a sensible default (e.g., `'pending'`/`'running'`) or making the column nullable, and keep the semantics consistent across the two tables.","suggestion_code":null,"existing_code":"{ name: 'status', type: 'varchar', length: '20' },"}
{"path":"apps/server/src/migrations/1784600000000-AddExamManagement.ts","start_line":18,"end_line":18,"category":"bug","severity":"medium","content":"The `updated_at` column defaults to CURRENT_TIMESTAMP but never auto-updates, so it will remain at the insert time for the life of the row. Every other table in this codebase (e.g., `classes`, `exam_scores` in InitialSchema) declares `updated_at ... ON UPDATE CURRENT_TIMESTAMP(6)` and uses `datetime(6)`/`CURRENT_TIMESTAMP(6)` precision. This table's `datetime`/`CURRENT_TIMESTAMP` (second precision) is also inconsistent with the rest of the schema and with TypeORM `@UpdateDateColumn`/`@CreateDateColumn` expectations. Consider aligning with the codebase convention.","suggestion_code":"{ name: 'updated_at', type: 'datetime(6)', default: 'CURRENT_TIMESTAMP(6)', onUpdate: 'CURRENT_TIMESTAMP(6)' },","existing_code":"{ name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' },"}
{"path":"apps/server/src/migrations/1784600000000-AddExamManagement.ts","start_line":5,"end_line":5,"category":"maintainability","severity":"low","content":"The `hasTable`/`hasColumn` guards wrap multiple DDL steps (table + FK + index). On MySQL (where DDL is auto-committed and not rolled back), if the migration fails after the table is created but before the FK/index is added (e.g., connection drop or constraint error), the migration is not recorded; on retry the guard short-circuits and the missing FK/index is silently never created, leaving a permanently inconsistent schema. Same risk applies to the `exam_scores` block. Consider checking each artifact (table, FK, index) independently instead of guarding the whole block, or removing the guards since migrations are executed once.","suggestion_code":null,"existing_code":"if (!(await queryRunner.hasTable('exams'))) {"}
{"path":"apps/server/src/migrations/1784600000000-AddExamManagement.ts","start_line":25,"end_line":27,"category":"maintainability","severity":"low","content":"The `class_id` foreign key has no explicit `onDelete`/`onUpdate` policy, unlike every other FK in this project which explicitly declares `ON DELETE NO ACTION ON UPDATE NO ACTION`. While TypeORM defaults to NO ACTION, leaving the policy implicit makes the delete behavior for `classes` unclear (deleting a class with exams will fail with an FK constraint error unless the app handles it). Recommend declaring the intended policy explicitly.","suggestion_code":" columnNames: ['class_id'],\n referencedTableName: 'classes',\n referencedColumnNames: ['id'],\n onDelete: 'NO ACTION',\n onUpdate: 'NO ACTION',","existing_code":" columnNames: ['class_id'],\n referencedTableName: 'classes',\n referencedColumnNames: ['id'],"}
{"path":"apps/server/src/migrations/1784920000000-DropAiMessageFeedback.ts","start_line":17,"end_line":26,"category":"maintainability","severity":"medium","content":"The down() migration re-adds the columns with hardcoded types (varchar(20)/varchar(500)) and NULL values, so a rollback will (1) not faithfully restore the original column definitions if they differed (e.g. text, enum, NOT NULL, defaults), and (2) permanently lose all feedback data that up() dropped, since the re-added columns are empty. If rollback data safety matters, consider capturing the original DDL before dropping, or at least document that rolling back is destructive.","suggestion_code":null,"existing_code":" if (!(await queryRunner.hasColumn('ai_messages', 'feedback'))) {\n await queryRunner.query(\n 'ALTER TABLE ai_messages ADD COLUMN feedback varchar(20) NULL',\n );\n }\n if (!(await queryRunner.hasColumn('ai_messages', 'feedback_reason'))) {\n await queryRunner.query(\n 'ALTER TABLE ai_messages ADD COLUMN feedback_reason varchar(500) NULL',\n );\n }"}
{"path":"apps/server/src/migrations/1784910000000-AddImportRuns.ts","start_line":11,"end_line":11,"category":"bug","severity":"medium","content":"The up() guard only checks for `import_runs` before returning. On MySQL, DDL is non-transactional, so if a previous run failed after creating `import_runs` (or `import_runs` was created out-of-band), this migration will record as executed while `import_steps` and `import_rows` are never created, leaving the schema permanently broken. Check each table independently (e.g., `if (await queryRunner.hasTable('import_steps')) return;` per table, or create each table with its own existence guard) instead of early-returning on the first table.","suggestion_code":" if (await queryRunner.hasTable('import_steps') || await queryRunner.hasTable('import_rows')) return;","existing_code":" if (await queryRunner.hasTable('import_runs')) return;"}
{"path":"apps/server/src/migrations/1784910000000-AddImportRuns.ts","start_line":27,"end_line":27,"category":"bug","severity":"low","content":"`updated_at` only gets a `DEFAULT CURRENT_TIMESTAMP`, so it will stay frozen at insert time unless application code explicitly sets it on every update. If any code path relies on DB-level auto-refresh, `updated_at` will be stale. Consider adding `onUpdate: 'CURRENT_TIMESTAMP'` (and keep it consistent with how entities manage this column in the ORM).","suggestion_code":" { name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP', onUpdate: 'CURRENT_TIMESTAMP' },","existing_code":" { name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' },"}
{"path":"apps/server/src/migrations/1784880000000-AddA2UiReviews.ts","start_line":9,"end_line":9,"category":"bug","severity":"low","content":"The early-return guard skips FK creation if the table already exists. If `ai_reviews` was created by another path (e.g., entity synchronize or a partially applied migration) without the FK, `up()` silently returns and `fk_ai_reviews_assistant_message` — which backs the entity's `@ManyToOne(..., onDelete: 'CASCADE')` cascade delete — is never created. Consider checking for the FK inside the guard (e.g., create it if missing) instead of returning unconditionally.","suggestion_code":null,"existing_code":" if (await queryRunner.hasTable('ai_reviews')) return;"}
{"path":"apps/server/src/migrations/1784880000000-AddA2UiReviews.ts","start_line":12,"end_line":13,"category":"maintainability","severity":"low","content":"This migration is nearly a verbatim copy of `1784870000000-AddA2UiForms.ts` (same column set, index names, FK pattern and even the `down` flow). Since these A2UI tables keep diverging (e.g., `sections_json` already had to be altered to LONGTEXT in a later migration), consider extracting a shared table-builder helper for the common columns/FK/index definitions to avoid drift and duplication.","suggestion_code":null,"existing_code":" new Table({\n name: 'ai_reviews',"}
{"path":"apps/server/src/migrations/1784930000000-AddImportRunSettings.ts","start_line":11,"end_line":11,"category":"maintainability","severity":"low","content":"settings_json 保存的是结构化配置(列映射、策略等),使用 text 类型无法利用 MySQL 5.7+ 原生 json 类型的 JSON 合法性校验,且应用侧每次都要手动 JSON.parse/stringifytext 列中非法 JSON 只能等到运行期才报错)。建议改用原生 json 类型:`ALTER TABLE import_runs ADD COLUMN settings_json json NULL`(与 ai_reviews.sections_json 用 text 不同那是为了兼容超大预览数据此处是小型设置对象json 类型更合适)。","suggestion_code":"'ALTER TABLE import_runs ADD COLUMN settings_json json NULL',","existing_code":"'ALTER TABLE import_runs ADD COLUMN settings_json text NULL',"}
{"path":"apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts","start_line":29,"end_line":40,"category":"bug","severity":"medium","content":"These `feedback`/`feedback_reason` columns are added here but are dropped again by the immediately following migration `1784920000000-DropAiMessageFeedback`, whose doc comment explicitly states the feedback feature has been removed from frontend and backend and the columns are being cleaned up. The two migrations contradict each other: the columns will exist only transiently and the final schema does not contain them. Please reconcile — either remove these two `addColumn` calls (and their `down` counterparts) if the drop migration is authoritative, or keep the additions and update the later drop migration accordingly.","suggestion_code":null,"existing_code":" await this.addColumn(queryRunner, 'ai_messages', new TableColumn({\n name: 'feedback',\n type: 'varchar',\n length: '20',\n isNullable: true,\n }));\n await this.addColumn(queryRunner, 'ai_messages', new TableColumn({\n name: 'feedback_reason',\n type: 'varchar',\n length: '500',\n isNullable: true,\n }));"}
{"path":"apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts","start_line":141,"end_line":147,"category":"performance","severity":"low","content":"This index on `message_id` alone is redundant: the composite primary key `(message_id, attachment_id)` already provides a B-tree index whose leftmost column is `message_id`, so lookups by `message_id` (including the FK integrity checks) already use the PK index. The extra index only adds write overhead on every insert/delete. If a reverse lookup ('which messages reference a given attachment') is needed, an index on `attachment_id` would be more useful; otherwise remove this index.","suggestion_code":null,"existing_code":" await queryRunner.createIndex(\n 'ai_message_attachments',\n new TableIndex({\n name: 'idx_ai_message_attachments_message',\n columnNames: ['message_id'],\n }),\n );"}
{"path":"apps/server/src/migrations/1784860000000-EnhanceAiChatForAntDesignX.ts","start_line":68,"end_line":77,"category":"performance","severity":"low","content":"Foreign-key columns are not automatically indexed on all databases (e.g., PostgreSQL), so the self-referencing `fk_ai_messages_reply_to` on `reply_to_message_id` may force expensive sequential scans for referential-integrity checks on every delete/update of `ai_messages` (relevant for the reply-thread deletion pattern). Consider adding an explicit index on `reply_to_message_id`, or rely on the automatic FK index only if the target database is guaranteed to create one (e.g., MySQL InnoDB).","suggestion_code":null,"existing_code":" await queryRunner.createForeignKey(\n 'ai_messages',\n new TableForeignKey({\n name: 'fk_ai_messages_reply_to',\n columnNames: ['reply_to_message_id'],\n referencedTableName: 'ai_messages',\n referencedColumnNames: ['id'],\n onDelete: 'SET NULL',\n }),\n );"}
{"path":"apps/server/src/migrations/1786001000000-AddA2UiSubmissions.ts","start_line":32,"end_line":36,"category":"bug","severity":"medium","content":"Inconsistency between up() and down(): up() returns early (does nothing) when the table already exists, but down() unconditionally drops the table whenever it exists. If this migration runs against an environment where `ai_a2ui_submissions` already existed before the migration (the exact case the up() guard is designed for), rolling back would destroy a pre-existing table and its data. down() should only drop the table if this migration actually created it (e.g., track creation state or drop only the index/table it owns).","suggestion_code":null,"existing_code":" async down(queryRunner: QueryRunner): Promise<void> {\n if (await queryRunner.hasTable('ai_a2ui_submissions')) {\n await queryRunner.dropTable('ai_a2ui_submissions');\n }\n }"}
{"path":"apps/server/src/migrations/1786001000000-AddA2UiSubmissions.ts","start_line":8,"end_line":8,"category":"bug","severity":"medium","content":"The early return skips index creation as well. If a previous run created the table but failed before creating the unique index (partial migration failure), or a pre-existing table lacks this index, re-running the migration will silently skip the index and the artifact_id + client_request_id idempotency guarantee will never be enforced. Consider checking for the index independently (e.g., hasIndex) and creating it if missing, rather than returning early on the table existence check.","suggestion_code":null,"existing_code":" if (await queryRunner.hasTable('ai_a2ui_submissions')) return;"}
{"path":"apps/server/src/migrations/1786001000000-AddA2UiSubmissions.ts","start_line":26,"end_line":27,"category":"bug","severity":"low","content":"The unique index relies on the database default collation. On MySQL with a case-insensitive collation (e.g., utf8mb4_0900_ai_ci), two client_request_id values that differ only by case (e.g., 'abc...' vs 'ABC...') would be treated as duplicates, which could falsely reject legitimate distinct submissions for the same artifact. For an idempotency key column, consider declaring a case-sensitive collation (e.g., utf8mb4_bin) on client_request_id or normalizing values consistently before insert.","suggestion_code":null,"existing_code":" columnNames: ['artifact_id', 'client_request_id'],\n isUnique: true,"}
{"path":"apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts","start_line":17,"end_line":17,"category":"bug","severity":"medium","content":"The original `sheets_json` column was created by the TypeORM migration 1784910000000 as `type: 'text'` with no `isNullable` (defaults to `false`, i.e. `NOT NULL`). In MySQL, `ALTER TABLE ... MODIFY COLUMN` replaces the full column definition, and omitting `NOT NULL` here silently converts the column to nullable — diverging from the schema created previously and from the entity definition (`@Column({ type: 'mediumtext' })`, also non-nullable by default). Preserve the original attributes, e.g. emit `MODIFY COLUMN sheets_json MEDIUMTEXT NOT NULL` (ideally reading `IS_NULLABLE`/`COLUMN_TYPE` from `information_schema.COLUMNS` alongside `DATA_TYPE`).","suggestion_code":" await queryRunner.query('ALTER TABLE `import_runs` MODIFY COLUMN `sheets_json` MEDIUMTEXT NOT NULL');","existing_code":" await queryRunner.query('ALTER TABLE `import_runs` MODIFY COLUMN `sheets_json` MEDIUMTEXT');"}
{"path":"apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts","start_line":23,"end_line":23,"category":"bug","severity":"medium","content":"The down migration reverts the column to TEXT (64KB). In strict SQL mode the ALTER will fail, and in non-strict mode it will silently truncate any row whose payload exceeds 64KB — precisely the data this migration was created to accommodate. Once the up migration has been applied with large payloads, rollback is unsafe. Consider cleaning/guarding oversized rows before reverting, or at least documenting this data-loss risk.","suggestion_code":null,"existing_code":" await queryRunner.query('ALTER TABLE `import_runs` MODIFY COLUMN `sheets_json` TEXT');"}
{"path":"apps/server/src/migrations/1786000000000-WidenImportSheetsJson.ts","start_line":16,"end_line":16,"category":"bug","severity":"low","content":"The guard only widens when the current type is not exactly `mediumtext`. If the column were already `longtext` (e.g. widened manually or by a future migration), this would shrink it back to MEDIUMTEXT and could fail/truncate. Consider widening only smaller text types (e.g. `current === 'text'`) to keep the migration purely widening/idempotent.","suggestion_code":" if (current && current.toLowerCase() === 'text') {","existing_code":" if (current && current.toLowerCase() !== 'mediumtext') {"}
{"path":"apps/server/src/notifications/dto/notification.dto.ts","start_line":15,"end_line":19,"category":"performance","severity":"medium","content":"class-validator executes all constraints on a property even after one fails (it collects every error), so when an attacker sends an oversized array, `@ArrayMaxSize(500)` fails but `@IsInt({ each: true })` still iterates and validates every element — validation cost scales linearly with the (unbounded) input size. To make this O(1) for oversized payloads, configure `stopAtFirstError: true` on the global ValidationPipe (so validation halts at the size check) or replace the `each` decorator with a short-circuiting custom validator that checks the length first.","suggestion_code":null,"existing_code":" @IsArray()\n @ArrayNotEmpty()\n @ArrayMaxSize(500)\n @IsInt({ each: true })\n recipientIds: number[];"}
{"path":"apps/server/src/notifications/dto/notification.dto.ts","start_line":49,"end_line":50,"category":"maintainability","severity":"low","content":"Business limits 500 and 100 are hardcoded magic numbers scattered across two DTOs. Extract them into named constants (e.g., `MAX_RECIPIENTS_PER_NOTIFICATION`, `MAX_PAGE_SIZE`) so they can be reused/consistent (the service and controller may also need the same bounds) and adjusted in one place.","suggestion_code":null,"existing_code":" @Max(100)\n limit?: number;"}
{"path":"apps/server/src/notifications/dto/notification.dto.ts","start_line":21,"end_line":23,"category":"maintainability","severity":"low","content":"`type` is validated only as a non-empty string; if notifications have a fixed set of business types this should be constrained with `@IsIn([...])`/an enum so invalid values are rejected at the boundary instead of being persisted. Also, `title`, `content`, and `link` have no max-length constraints, allowing arbitrarily large strings to be stored — consider `@MaxLength` on each (especially `title`/`link`).","suggestion_code":null,"existing_code":" @IsString()\n @IsNotEmpty()\n type: string;"}
{"path":"apps/server/src/occupancies/occupancies.service.ts","start_line":75,"end_line":80,"category":"bug","severity":"high","content":"Concurrency hazard: the check for an existing active occupancy uses `SELECT ... FOR UPDATE` on a row that does not yet exist (phantom read), so it acquires no lock. Two concurrent check-ins for the same student (e.g., in different rooms) can both pass this check, create two active occupancy records, and double-collect the deposit (the deposit row is also read without a lock, causing lost updates). Fix by locking a stable row (e.g., `SELECT ... FOR UPDATE` on the Student row before this check) or by adding a DB-level unique partial index on `(student_id) WHERE check_out_date IS NULL`.","suggestion_code":null,"existing_code":" const existing = await this.withPessimisticWriteLock(\n manager\n .createQueryBuilder(Occupancy, 'occupancy')\n .where('occupancy.studentId = :studentId', { studentId: dto.studentId })\n .andWhere('occupancy.checkOutDate IS NULL'),\n ).getOne();"}
{"path":"apps/server/src/occupancies/occupancies.service.ts","start_line":93,"end_line":93,"category":"bug","severity":"medium","content":"`room.capacity ?? 0` makes a room with NULL/undefined capacity always appear full (`count >= 0` is always true), permanently blocking check-in; likewise `count + 1 >= (room.capacity ?? 0)` immediately sets such a room to 'full'. If a NULL capacity is a valid state (e.g., meaning unlimited), this logic is wrong. Clarify the semantics (e.g., treat NULL as unlimited) or make capacity non-nullable.","suggestion_code":null,"existing_code":" if (count >= (room.capacity ?? 0)) throw new BadRequestException('宿舍已满');"}
{"path":"apps/server/src/occupancies/occupancies.service.ts","start_line":150,"end_line":150,"category":"bug","severity":"medium","content":"`dto.depositAmount` is persisted without validation. The private `normalizePositiveMoney` helper already exists for this but is never called: a negative or non-finite `depositAmount` would be stored as-is, and if an existing `deposit.amount` is NaN the sum becomes NaN (`Number('NaN')`) and gets saved. Apply `normalizePositiveMoney` to `dto.depositAmount` before use, in both the create and update branches.","suggestion_code":null,"existing_code":" amount: dto.depositAmount ?? 500,"}
{"path":"apps/server/src/occupancies/occupancies.service.ts","start_line":169,"end_line":169,"category":"maintainability","severity":"low","content":"Dead code: the private methods `normalizePositiveMoney` and `assertDateOnly` are never called anywhere in this file (the `checkIn` flow doesn't validate date formats or deposit amounts). Either wire them into `checkIn` or remove them to avoid misleading future maintainers.","suggestion_code":null,"existing_code":" private normalizePositiveMoney(value: number, label: string): number {"}
{"path":"apps/server/src/occupancies/occupancies.service.ts","start_line":73,"end_line":73,"category":"bug","severity":"medium","content":"Date ordering relies on lexicographic string comparison, but no format validation is done before `assertDateOrder`. Non-`YYYY-MM-DD` values (e.g., `2024/01/01`, `2024-1-1`) would not be rejected, can produce wrong ordering results, and are then persisted. Call `assertDateOnly` on `checkInDate`/`billingStartDate` before the comparison (the helper already exists but is unused).","suggestion_code":null,"existing_code":" this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期');"}
{"path":"apps/server/src/occupancies/dto/occupancy.dto.ts","start_line":99,"end_line":100,"category":"bug","severity":"medium","content":"`@IsArray()` only validates that the value is an array — it does not validate the element type. Payloads like `[\"1\", \"2\"]` or `[\"abc\"]` pass validation and reach the service as `string[]`, which can break downstream logic that assumes `number[]` (e.g., DB IN queries, `ids.includes(...)`). Add `@IsInt({ each: true })` to validate every element, and consider `@ArrayNotEmpty()` to reject empty batch requests.","suggestion_code":" @IsArray()\n @IsInt({ each: true })\n @ArrayNotEmpty()\n ids: number[];","existing_code":" @IsArray()\n ids: number[];"}
{"path":"apps/server/src/occupancies/dto/occupancy.dto.ts","start_line":33,"end_line":35,"category":"bug","severity":"medium","content":"`stayType` is unconstrained free text, but the domain only uses the fixed vocabulary `'long'`/`'short'`: billing generation branches on `stayType === 'long'`, and the import path normalizes the value before persisting. Since this DTO passes `dto.stayType` straight through to the entity, a typo like `'Long'` or `'temporary'` would silently persist inconsistent data that is then misclassified as short-term. Constrain the field with `@IsIn(['long', 'short'])`.","suggestion_code":" @IsOptional()\n @IsString()\n @IsIn(['long', 'short'])\n stayType?: string;","existing_code":" @IsOptional()\n @IsString()\n stayType?: string;"}
{"path":"apps/server/src/occupancies/dto/occupancy.dto.ts","start_line":20,"end_line":22,"category":"maintainability","severity":"low","content":"The `@Matches(/^\\d{4}-\\d{2}-\\d{2}$/)` + `@IsISO8601({ strict: true })` pair is duplicated 8 times across these DTOs, and the `@Matches` part is redundant: `@IsISO8601({ strict: true })` already enforces the date-only format and calendar validity (confirmed by the boundary spec rejecting `'2026-7-13'`, `'2026-02-31'`, and `'2026-07-13T00:00:00Z'`). Consider extracting a shared custom decorator (e.g., `@IsDateOnly()`) so the date rule is defined once and all date fields get consistent validation and error messages.","suggestion_code":null,"existing_code":" @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601({ strict: true })\n checkInDate: string; // YYYY-MM-DD"}
{"path":"apps/server/src/occupancies/occupancy-lock.ts","start_line":5,"end_line":8,"category":"maintainability","severity":"low","content":"The `_dataSource` parameter is never used inside the function body. Every call site (e.g. `occupancy-operations.service.ts`) is forced to pass `this.dataSource` for no effect, and this diverges from the identical private helper in `occupancies.service.ts`, which takes no DataSource. Remove the redundant parameter (and update call sites) so the API is honest about its dependencies; if a DataSource is genuinely needed later (e.g. to validate driver lock support), add it back with a real use. Also note this helper relies on the caller running the query inside a transaction — outside one, pessimistic locks are released immediately and provide no protection — so consider documenting that contract here.","suggestion_code":"): SelectQueryBuilder<T> {\n return qb.setLock('pessimistic_write');\n}","existing_code":" _dataSource: DataSource,\n): SelectQueryBuilder<T> {\n return qb.setLock('pessimistic_write');\n}"}
{"path":"apps/server/src/notifications/notifications.controller.ts","start_line":30,"end_line":30,"category":"security","severity":"medium","content":"The class-level `@RequirePermission('notification:view')` also gates the two write endpoints `markRead` (`PUT :id/read`) and `markAllRead` (`PUT read-all`). A role that only holds the read permission `notification:view` (e.g., an auditor) can mutate notification state. If the permission model distinguishes read vs. write, these PUT handlers should require an update permission (e.g., `notification:update`); otherwise the semantics should be documented as intentional.","suggestion_code":null,"existing_code":"@RequirePermission('notification:view')"}
{"path":"apps/server/src/notifications/notifications.controller.ts","start_line":54,"end_line":56,"category":"bug","severity":"medium","content":"SSE cleanup relies solely on the request `close` event. If it does not fire (e.g., abrupt network drop behind some proxies), `service.unsubscribe(userId)` is never called and both the `interval(25_000)` heartbeat timer and the user's `Subject` subscription in the service leak indefinitely (the heartbeat never completes on its own). Prefer tying cleanup to the observable lifecycle with RxJS `finalize(() => this.service.unsubscribe(userId))` and removing the `req.on('close')` listener — adding finalize on top of the existing listener would double-decrement the service's ref-count and could complete the Subject while another tab of the same user is still connected.","suggestion_code":null,"existing_code":" req.on('close', () => {\n this.service.unsubscribe(userId);\n });"}
{"path":"apps/server/src/notifications/notifications.controller.ts","start_line":81,"end_line":81,"category":"maintainability","severity":"low","content":"`id` is already converted to `number` by `ParseIntPipe`, so `+id` is redundant. Use `id` directly for clarity.","suggestion_code":null,"existing_code":" await this.service.markRead(+id, req.user.id);"}
{"path":"apps/server/src/occupancies/occupancies.controller.ts","start_line":94,"end_line":96,"category":"security","severity":"high","content":"DTO validation is not enforced on these mutation routes. Only `batchRestore` applies `@UsePipes(new ValidationPipe(...))`, and there is no global ValidationPipe registered (main.ts / APP_PIPE don't register one), so the class-validator decorators on `CheckInDto`, `CheckOutDto`, `TransferRoomDto` and `BatchCheckOutDto` (required fields, `@IsInt`, `@IsISO8601`, `@Min`, etc.) are never executed. Malformed or extra fields reach the service and DB unchecked. Apply the same pipe at the controller level (or on each route).","suggestion_code":" @Post('check-in')\n @RequirePermission('occupancy:checkin')\n @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))\n async checkIn(@Body() dto: CheckInDto, @Request() req: AuthenticatedRequest) {","existing_code":" @Post('check-in')\n @RequirePermission('occupancy:checkin')\n async checkIn(@Body() dto: CheckInDto, @Request() req: AuthenticatedRequest) {"}
{"path":"apps/server/src/occupancies/occupancies.controller.ts","start_line":104,"end_line":105,"category":"bug","severity":"medium","content":"`void this.notificationsService.create(...)` is fire-and-forget: since the promise is not awaited, the surrounding try/catch can never intercept its rejection. If `create` rejects (DB/notification failure), it becomes an unhandled promise rejection, which crashes the process on Node >= 15 by default — the exact opposite of the intended 'don't block response' behavior. Await inside the try block (or attach `.catch()`). The check-out handler below has the same pattern and needs the same fix.","suggestion_code":" if (student?.userId) {\n await this.notificationsService.create({","existing_code":" if (student?.userId) {\n void this.notificationsService.create({"}
{"path":"apps/server/src/occupancies/occupancies.controller.ts","start_line":68,"end_line":68,"category":"bug","severity":"low","content":"`active: active === 'true'` conflates 'parameter not provided' with 'explicitly false': when the query param is absent the expression evaluates to `false`, which is indistinguishable from a user passing `active=false`. The service only applies the check-out filter for truthy values (`if (query?.active) qb.andWhere('o.checkOutDate IS NULL')`), so an explicit `active=false` silently returns the default active list instead of inactive/checked-out records. Pass `undefined` when the parameter is missing and let the service handle explicit `false`, or document that only `active=true` has an effect.","suggestion_code":" active: active === undefined ? undefined : active === 'true',","existing_code":" active: active === 'true',"}
{"path":"apps/server/src/occupancies/occupancies.controller.ts","start_line":279,"end_line":279,"category":"bug","severity":"medium","content":"`Number(depositAmount)` on an arbitrary query string can produce `NaN` (e.g. `?depositAmount=abc`), which is then passed into the batch import and likely persisted without validation. Validate the value with `Number.isFinite` and throw `BadRequestException` (or fall back to `undefined`) when it isn't a finite number.","suggestion_code":" const deposit = depositAmount?.trim() ? Number(depositAmount) : undefined;\n if (deposit !== undefined && !Number.isFinite(deposit)) {\n throw new BadRequestException('押金金额格式不正确');\n }","existing_code":" depositAmount: depositAmount?.trim() ? Number(depositAmount) : undefined,"}
{"path":"apps/server/src/occupancies/occupancies.controller.ts","start_line":219,"end_line":220,"category":"security","severity":"medium","content":"User-controlled values (student name, phone, ID number, notes, room number, etc.) are written into the exported Excel workbook without sanitization. A value starting with `=`, `+`, `-`, or `@` will be interpreted as a formula when the file is opened (spreadsheet formula injection), which can execute arbitrary expressions / exfiltrate data. Escape such prefixes (e.g. prefix with `'`) or write values as plain text before adding rows.","suggestion_code":null,"existing_code":" for (const r of records) {\n ws.addRow({"}
{"path":"apps/server/src/occupancies/occupancies.controller.ts","start_line":264,"end_line":264,"category":"security","severity":"medium","content":"`FileInterceptor('file')` is used without a `limits` option, and the entire uploaded file is buffered into memory before being parsed by ExcelJS. A large or malicious upload can exhaust server memory (DoS). Add a size limit (e.g. `limits: { fileSize: ... }`) and validate `file.size` before parsing.","suggestion_code":" @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))","existing_code":" @UseInterceptors(FileInterceptor('file'))"}
{"path":"apps/server/src/occupancies/occupancies.controller.ts","start_line":164,"end_line":164,"category":"maintainability","severity":"low","content":"`batchRemove`/`batchPurge` accept an unvalidated inline body type `{ ids: number[] }` with no validation pipe: non-numeric `ids` reach the service and DB, and no whitelist/forbidNonWhitelisted applies. This is also inconsistent with `batchRestore`, which uses the shared `BatchIdsDto` with `@UsePipes`. Reuse `BatchIdsDto` plus the same ValidationPipe here.","suggestion_code":" @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))\n async batchRemove(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {","existing_code":" async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {"}
{"path":"apps/server/src/occupancies/occupancy-import.service.ts","start_line":139,"end_line":139,"category":"bug","severity":"high","content":"`isHistoricalRecord` is defined solely by the *presence* of checkOutDate, so any row with a future (planned) check-out date is treated as a historical record. That bypasses the active-occupancy conflict check, the room-capacity check, bed/locker status updates, and deposit collection — a live/current check-in with an end date can over-fill a room and leave the bed/locker marked 'available'. Historical should mean the check-out date is in the past, e.g. `checkOutDate < today`.","suggestion_code":"const today = dayjs().utcOffset(8).format('YYYY-MM-DD');\n const isHistoricalRecord = Boolean(checkOutDate && checkOutDate < today);","existing_code":"const isHistoricalRecord = Boolean(checkOutDate);"}
{"path":"apps/server/src/occupancies/occupancy-import.service.ts","start_line":247,"end_line":250,"category":"bug","severity":"medium","content":"When a student's existing deposit was fully refunded (status 'refunded'), re-importing their row silently resets the deposit to 'paid' and erases the refund audit trail (refundDate/refundAmount/refundedBy/refundedAt) without any record that a refund ever happened. This loses financial history and can produce inconsistent accounting. Also, these fields are already typed on the Deposit entity, so the `as` casts are unnecessary. Prefer creating a new deposit record for the re-charge instead of mutating the refunded one, or at least guard against wiping existing refund data.","suggestion_code":null,"existing_code":"(existingDeposit as { refundDate: string | null }).refundDate = null;\n (existingDeposit as { refundAmount: number | null }).refundAmount = null;\n (existingDeposit as { refundedBy: number | null }).refundedBy = null;\n (existingDeposit as { refundedAt: Date | null }).refundedAt = null;"}
{"path":"apps/server/src/occupancies/occupancy-import.service.ts","start_line":161,"end_line":164,"category":"bug","severity":"medium","content":"The occupancy count check and the subsequent insert are not atomic (no locking), so concurrent imports/check-ins can overbook a room beyond its capacity — both rows can read the same `count` and both pass. Likewise, the find-then-create pattern for room/student/bed can throw unique-constraint errors when duplicate rows or concurrent requests race. Consider a pessimistic lock on the room row (e.g. `setLock('pessimistic_write')` on the Room query) or rely on unique constraints with catch-and-retry/upsert.","suggestion_code":null,"existing_code":"const count = await occupancyRepo.count({\n where: { roomId: room.id, checkOutDate: IsNull() },\n });\n if (!isHistoricalRecord && count >= (room.capacity ?? 0)) {"}
{"path":"apps/server/src/occupancies/occupancy-import.service.ts","start_line":154,"end_line":155,"category":"bug","severity":"medium","content":"For historical records the overlap check is skipped entirely, so back-filled data can create two occupancy records with overlapping date ranges for the same student/room/bed (e.g. the same bed assigned to two students in the same period). Consider validating that no existing occupancy overlaps the [checkInDate, checkOutDate] interval for the student/room/bed before inserting a historical record.","suggestion_code":null,"existing_code":"if (existing && !isHistoricalRecord) {\n throw new ImportRowSkipped("}
{"path":"apps/server/src/occupancies/occupancy-import.service.ts","start_line":235,"end_line":237,"category":"bug","severity":"low","content":"`findOne` without ordering or status filtering returns an arbitrary deposit when the student has multiple deposit records (e.g. several historical/replaced deposits), so the import may update the wrong one or decide the wrong amount. Order by `id DESC` (most recent) or filter by an active status to make the re-charge logic deterministic.","suggestion_code":null,"existing_code":"const existingDeposit = await depositRepo.findOne({\n where: { studentId: student.id },\n });"}
{"path":"apps/server/src/occupancies/occupancy-import.service.ts","start_line":59,"end_line":62,"category":"bug","severity":"low","content":"Rows missing name/roomNumber are counted in `skipped` but never added to the returned `errors` list, so the caller has no way to identify which rows were skipped and why. Add an error entry with the row number for these rows as well.","suggestion_code":"if (!row.name?.trim() || !row.roomNumber?.trim()) {\n skipped++;\n errors.push(`第${rowNum}行: 缺少姓名或宿舍号,跳过`);\n continue;\n }","existing_code":"if (!row.name?.trim() || !row.roomNumber?.trim()) {\n skipped++;\n continue;\n }"}
{"path":"apps/server/src/occupancies/occupancy-import.service.ts","start_line":252,"end_line":253,"category":"bug","severity":"low","content":"This branch increments `depositsCreated` even though an existing deposit was *updated*, not created, which makes the reported `depositsCreated` count misleading in the returned summary. Consider a separate counter (e.g. `depositsUpdated`) or only count genuinely new insertions.","suggestion_code":null,"existing_code":"await depositRepo.save(existingDeposit);\n rowDepositsCreated++;"}
{"path":"apps/server/src/notifications/notifications.service.ts","start_line":82,"end_line":83,"category":"bug","severity":"medium","content":"The manual refcount (`subscriberCounts`) is decoupled from the actual RxJS subscriptions on the returned Observable. `subscribe()` increments the count before the observable is ever subscribed, and cleanup depends entirely on every caller remembering to invoke `unsubscribe()` exactly once per connection (in the controller, only via `req.on('close')`). Failure modes: (1) if a connection drops without the `close` event firing (proxy timeout, process kill, abrupt network drop), the Subject and its observers stay in `subjects`/`subscriberCounts` forever, causing an unbounded memory leak per user over the service singleton's lifetime; (2) if `subscribe()` and `unsubscribe()` are ever invoked a different number of times than the real subscriptions (e.g. subscribe called twice for one SSE connection), the count reaches 0 while observers are still active and `subj.complete()` silently kills a live stream. Consider tracking the actual `Subscription` objects (e.g. wrap with `finalize` to decrement on real teardown) instead of a parallel counter.","suggestion_code":null,"existing_code":" this.subscriberCounts.set(userId, (this.subscriberCounts.get(userId) ?? 0) + 1);\n return this.subjects.get(userId)!.asObservable();"}
{"path":"apps/server/src/notifications/notifications.service.ts","start_line":47,"end_line":48,"category":"bug","severity":"medium","content":"Cursor pagination is inconsistent: rows are ordered by `n.createdAt DESC` but the next-page cursor advances by `n.id < :after`. `create()` inserts all recipients' notifications in a single `repo.save` call, so those rows get the same per-statement `CURRENT_TIMESTAMP` (identical `createdAt`). With ties, the ordering is non-deterministic, so a `take(safeLimit)` page can return a non-monotonic id window and the next `id < after` query will skip or repeat notifications. Since `id` is monotonic, order by `n.id DESC` (and keep the cursor on `id`) to make pagination stable.","suggestion_code":" .orderBy('n.id', 'DESC')\n .take(safeLimit);","existing_code":" .orderBy('n.createdAt', 'DESC')\n .take(safeLimit);"}
{"path":"apps/server/src/notifications/notifications.service.ts","start_line":31,"end_line":31,"category":"bug","severity":"low","content":"`dto.recipientIds` are user-supplied but never validated against existing users before the bulk save. `Notification.recipient` has a foreign key (CASCADE), so an unknown recipientId will throw a raw DB constraint error from `repo.save`, which NestJS surfaces as a generic 500 (potentially leaking DB details in dev). Validate that all recipients exist and return a user-friendly 400 (BadRequestException) on failure, or wrap the save in a try/catch to map FK violations to a 4xx.","suggestion_code":null,"existing_code":" const saved = await this.repo.save(notifications);"}
{"path":"apps/server/src/operation-logs/operation-logs.controller.ts","start_line":37,"end_line":41,"category":"security","severity":"medium","content":"The audit endpoint accepts client-controlled `module`, `action`, `targetId`, `targetType`, and `detail` (up to 2000 chars) and stores them verbatim in the audit log with no sanitization. A caller holding `log:create` can forge arbitrary audit entries (e.g., claim actions never performed, or inject CRLF/control characters to splice fake log lines), undermining the integrity of the audit trail. Since the codebase already writes server-side audit entries via `withAuditLog`, consider removing this client-facing endpoint or restricting it to a trusted admin role; at minimum, strip control characters (\\r\\n, etc.) from the payload and never treat log fields as trusted data.","suggestion_code":null,"existing_code":" module: body.module,\n action: body.action,\n targetId: body.targetId,\n targetType: body.targetType,\n detail: body.detail,"}
{"path":"apps/server/src/operation-logs/operation-logs.controller.ts","start_line":29,"end_line":29,"category":"bug","severity":"low","content":"`createAuditLog` is an async handler with no error handling: any failure from `extractRequestInfo`/`service.log` propagates as an unhandled 500. While NestJS global exception filters may map thrown errors, the review standard requires async functions to include proper error handling with user-friendly messages. Wrap the service call in try/catch and rethrow a mapped HTTP exception (e.g., `BadRequestException` for validation, `InternalServerErrorException` for persistence failures), or delegate explicitly via an exception filter.","suggestion_code":null,"existing_code":" async createAuditLog("}
{"path":"apps/server/src/operation-logs/operation-logs.controller.ts","start_line":9,"end_line":13,"category":"maintainability","severity":"low","content":"This local interface re-declares `headers`/`connection`, duplicating the `RequestInfoSource` type already exported from `../common/request-utils` (which other controllers import and combine with `{ user: AuthenticatedUser }`). Reuse it to avoid divergence: `interface AuthenticatedRequest extends RequestInfoSource { user: AuthenticatedUser }`.","suggestion_code":"interface AuthenticatedRequest extends RequestInfoSource {\n user: AuthenticatedUser;\n}","existing_code":"interface AuthenticatedRequest {\n user: AuthenticatedUser;\n headers?: Record<string, string | string[] | undefined>;\n connection?: { remoteAddress?: string };\n}"}
{"path":"apps/server/src/organizations/dto/organization.dto.ts","start_line":59,"end_line":61,"category":"bug","severity":"medium","content":"`@IsEnum` expects an enum object, not an array of values. Passing `['active', 'archived']` makes class-validator treat the array as the enum (keys `0`/`1`), so validation either always fails (class-validator >= 0.14 explicitly rejects arrays) or produces misleading errors like \"must be one of the following values: 0, 1\". The rest of this codebase uses the dedicated `@IsIn([...])` decorator for string value lists (e.g. `expense.dto.ts`, `student.dto.ts`). Use `@IsIn(['active', 'archived'])` and swap the `IsEnum` import for `IsIn`.","suggestion_code":" @IsOptional()\n @IsIn(['active', 'archived'])\n status?: 'active' | 'archived';","existing_code":" @IsOptional()\n @IsEnum(['active', 'archived'])\n status?: 'active' | 'archived';"}
{"path":"apps/server/src/organizations/dto/organization.dto.ts","start_line":4,"end_line":6,"category":"bug","severity":"low","content":"The entity columns have explicit length limits (name: 100, code: 50, contactName: 50, phone: 30, color: 20), but the DTOs impose no maximum length. Over-length input will pass validation and then fail at the DB layer (e.g. MySQL strict mode \"Data too long\"), surfacing as a 500 instead of a 400. Add `@MaxLength` decorators matching the entity column lengths for these fields.","suggestion_code":" @IsString()\n @IsNotEmpty()\n @MaxLength(100)\n name: string;","existing_code":" @IsString()\n @IsNotEmpty()\n name: string;"}
{"path":"apps/server/src/operation-logs/operation-logs.service.ts","start_line":29,"end_line":32,"category":"maintainability","severity":"medium","content":"Domain-specific business logic is hardcoded inside a generic log service: the module name '考勤管理' and the four Chinese action names are magic strings that must stay in sync with the code that actually writes these log entries. Any rename/typo elsewhere silently breaks this query (returns no/wrong latest pull) with no compile-time safety. Extract these to shared constants (e.g. a MODULE/ACTION enum or constants file used by both the log-writer and this query), or move this method into the attendance module that owns the DingTalk pull flow.","suggestion_code":null,"existing_code":" .where('log.module = :module', { module: '考勤管理' })\n .andWhere('log.action IN (:...actions)', {\n actions: ['拉取钉钉课程考勤', '查看已拉取课程考勤', '钉钉考勤导入', '刷新钉钉考勤'],\n })"}
{"path":"apps/server/src/operation-logs/operation-logs.service.ts","start_line":53,"end_line":54,"category":"bug","severity":"medium","content":"Fragile date handling: `query.endDate + ' 23:59:59'` assumes `endDate` is a plain `YYYY-MM-DD` string, but the service signature accepts arbitrary strings and only the controller's DTO enforces that format. A direct caller passing a datetime (e.g. '2026-08-09 10:00') produces '2026-08-09 10:00 23:59:59', which is malformed and yields wrong/no results. The same applies to the lexicographic `query.startDate > query.endDate` comparison, which is only correct for zero-padded `YYYY-MM-DD`. Also, `createdAt <= '... 23:59:59'` excludes records created in the last second with fractional time. Prefer parsing to `Date` objects for the comparison and filtering with a half-open range `createdAt >= startDate AND createdAt < endDate + 1 day`.","suggestion_code":null,"existing_code":" if (query?.endDate)\n qb.andWhere('log.createdAt <= :endDate', { endDate: query.endDate + ' 23:59:59' });"}
{"path":"apps/server/src/organizations/organizations.controller.ts","start_line":62,"end_line":72,"category":"bug","severity":"medium","content":"Logging happens after the business operation succeeds but without a try/catch. If `logService.log` throws (e.g., transient DB error), the mutation already committed but the API returns an error — the client may retry and create duplicate records / double-apply changes. This pattern exists in create/update/remove/purge. Wrap the log call in try/catch so a logging failure cannot break the primary operation's response.","suggestion_code":" try {\n await this.logService.log({\n userId: req.user?.id,\n username: req.user?.username,\n module: '机构管理',\n action: '新增机构',\n targetId: result.id,\n targetType: 'organization',\n detail: dto.name,\n ipAddress,\n userAgent,\n });\n } catch {\n // 审计日志失败不应影响主流程\n }","existing_code":" await this.logService.log({\n userId: req.user?.id,\n username: req.user?.username,\n module: '机构管理',\n action: '新增机构',\n targetId: result.id,\n targetType: 'organization',\n detail: dto.name,\n ipAddress,\n userAgent,\n });"}
{"path":"apps/server/src/organizations/organizations.controller.ts","start_line":88,"end_line":88,"category":"security","severity":"low","content":"`JSON.stringify(dto)` writes the entire submitted payload — including PII fields `phone` and `contactName` (see UpdateOrganizationDto) — into the operation log. This leaks personal data into audit logs and any log pipeline that consumes them. Log only non-sensitive identifying fields (e.g., name/code), or redact PII.","suggestion_code":" detail: JSON.stringify({ name: dto.name, code: dto.code }),","existing_code":" detail: JSON.stringify(dto),"}
{"path":"apps/server/src/organizations/organizations.controller.ts","start_line":98,"end_line":109,"category":"maintainability","severity":"low","content":"The `extractRequestInfo(req)` + `this.logService.log({...})` boilerplate is duplicated verbatim in all four mutation handlers (create/update/remove/purge) with only action/detail varying. Extract a private helper (e.g., `private async logAction(req, action, targetId, detail?)`) to reduce duplication and ensure consistent error handling in one place.","suggestion_code":null,"existing_code":" const { ipAddress, userAgent } = extractRequestInfo(req);\n const result = await this.service.remove(+id);\n await this.logService.log({\n userId: req.user?.id,\n username: req.user?.username,\n module: '机构管理',\n action: '归档机构',\n targetId: +id,\n targetType: 'organization',\n ipAddress,\n userAgent,\n });"}
{"path":"apps/server/src/organizations/organizations.module.ts","start_line":26,"end_line":27,"category":"bug","severity":"medium","content":"The bare `catch` swallows ALL exceptions thrown by `getHostOrganization()`, not just the `NotFoundException('尚未配置本机构')` that signals a missing host. If a real error occurs (DB down, query failure, etc.), it is masked and the code blindly attempts to create a host organization. Worse, if a host org exists but is archived, `getHostOrganization()` (filters `status: 'active'`) throws NotFound while `create()`'s duplicate guard (no status filter) throws `BadRequestException('本机构已存在...')` — causing a confusing startup crash. Restrict the catch to the not-found case and rethrow anything else.","suggestion_code":" } catch (error) {\n if (!(error instanceof NotFoundException)) throw error;\n await this.service.create({","existing_code":" } catch {\n await this.service.create({"}
{"path":"apps/server/src/organizations/organizations.module.ts","start_line":23,"end_line":25,"category":"bug","severity":"low","content":"This seed is not idempotent under concurrent startup. If multiple app instances boot simultaneously and all see no host organization, each will call `create({ isHost: true })`. The loser hits `create()`'s guard (`BadRequestException('本机构已存在,只能配置一个本机构')`), which propagates out of `onModuleInit` and crashes that instance. Consider making the check-and-create atomic (e.g., a unique partial index on `isHost`, or catching/retrying the race) to keep startup reliable in multi-instance deployments.","suggestion_code":null,"existing_code":" async onModuleInit() {\n try {\n await this.service.getHostOrganization();"}
{"path":"apps/server/src/occupancies/occupancy-operations.service.ts","start_line":76,"end_line":78,"category":"bug","severity":"high","content":"Unconditionally setting the room to 'available' is incorrect for multi-occupancy rooms (capacity > 1 is clearly supported here — see the capacity/count logic in transferRoom). After one resident checks out, other active occupancies may remain, so the room should only become 'available' when no active occupancy is left; otherwise it should stay 'occupied'. It also overwrites an existing 'maintenance' status. Please count remaining active occupancies (checkOutDate IS NULL) before deciding the new status.","suggestion_code":null,"existing_code":" if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' });\n if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' });\n await manager.update(Room, occ.roomId, { status: 'available' });"}
{"path":"apps/server/src/occupancies/occupancy-operations.service.ts","start_line":369,"end_line":369,"category":"bug","severity":"medium","content":"Same issue as in checkOut(): in a multi-occupancy room (capacity > 1), marking the room 'available' after a single resident checks out is wrong — other active occupancies may still be in the room, and any 'maintenance' status would be overwritten. Recompute the room status from remaining active occupancies.","suggestion_code":null,"existing_code":" await runner.manager.update(Room, occ.roomId, { status: 'available' });"}
{"path":"apps/server/src/occupancies/occupancy-operations.service.ts","start_line":116,"end_line":116,"category":"bug","severity":"medium","content":"Same room-status issue: after transferring one resident out of the old room, the room is forced to 'available' even if other active occupancies remain (capacity > 1) or the room was 'maintenance'. Recompute status based on remaining active occupancies instead of hardcoding 'available'.","suggestion_code":null,"existing_code":" await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });"}
{"path":"apps/server/src/occupancies/occupancy-operations.service.ts","start_line":191,"end_line":193,"category":"bug","severity":"medium","content":"When the new room becomes occupied but is not yet full (count + 1 < capacity), the status is left unchanged — so a room can display 'available' while having residents, or remain stale 'full' after a previous checkout left a vacancy. Add an else branch that sets status to 'occupied' (or recompute from count).","suggestion_code":null,"existing_code":" if (count + 1 >= (newRoom.capacity ?? 0)) {\n await runner.manager.update(Room, newRoom.id, { status: 'full' });\n }"}
{"path":"apps/server/src/occupancies/occupancy-operations.service.ts","start_line":341,"end_line":344,"category":"bug","severity":"medium","content":"Unlike checkOut(), the occupancy rows in batchCheckOut are read without a pessimistic write lock inside the transaction. Concurrent batchCheckOut/checkOut calls for the same id can both observe checkOutDate == null and process the same record twice (double bed/locker release and inflated success count). Lock the row per id (e.g., SELECT ... FOR UPDATE via the lock helper) before processing.","suggestion_code":null,"existing_code":" const occ = await runner.manager.findOne(Occupancy, {\n where: { id },\n relations: ['student'],\n });"}
{"path":"apps/server/src/occupancies/occupancy-operations.service.ts","start_line":45,"end_line":46,"category":"maintainability","severity":"low","content":"assertDateOnly() (and normalizePositiveMoney() above it) are never invoked anywhere in this file — dead code. Either wire them into the validation paths or remove them.","suggestion_code":null,"existing_code":" private assertDateOnly(value: string, label: string): void {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value || '')) {"}
{"path":"apps/server/src/occupancies/occupancy-operations.service.ts","start_line":20,"end_line":21,"category":"maintainability","severity":"low","content":"roomRepo, studentRepo, depositRepo, bedRepo, lockerRepo, and organizationRepo are injected but never used in this service (all reads/writes go through the transaction manager, and OccupancyImportService is instantiated directly with dataSource). Remove the unused injections to avoid unnecessary DI overhead and confusion.","suggestion_code":null,"existing_code":" @InjectRepository(Occupancy) private repo: Repository<Occupancy>,\n @InjectRepository(Room) private roomRepo: Repository<Room>,"}
{"path":"apps/server/src/occupancies/occupancy-operations.service.ts","start_line":340,"end_line":341,"category":"performance","severity":"low","content":"batchCheckOut performs a findOne + several UPDATEs per id in a sequential loop (N+1 queries), which can be slow for large batches. Load all records up front with `where: { id: In(dto.ids) }` in a single query, then iterate for per-record validation/saves.","suggestion_code":null,"existing_code":" for (const id of dto.ids) {\n const occ = await runner.manager.findOne(Occupancy, {"}
{"path":"apps/server/src/occupancies/occupancy-import-template.ts","start_line":72,"end_line":73,"category":"bug","severity":"medium","content":"`String(cell.value)` produces `[object Object]` for ExcelJS rich-text cells (`{ richText: [...] }`) and formula cells (`{ formula, result }`), because neither has a top-level `text` property and both fall through the `'text' in cell.value` check. Imported names/dates computed via formulas or rich text will silently become the literal string `[object Object]` and corrupt the resulting rows. Handle `richText` (join `richText[].text`) and formula cells (use `result`) explicitly.","suggestion_code":" if (typeof cell.value === 'object' && 'richText' in cell.value) {\n return cell.value.richText.map((t) => t.text).join('').trim();\n }\n if (typeof cell.value === 'object' && 'result' in cell.value) {\n return String(cell.value.result).trim();\n }\n return String(cell.value).trim();","existing_code":" // eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本/公式对象,原样保留其字符串化结果\n return String(cell.value).trim();"}
{"path":"apps/server/src/occupancies/occupancy-import-template.ts","start_line":0,"end_line":0,"category":"bug","severity":"low","content":"When the cell doesn't match the `YYYY-MM-DD`-style regex, `parseDate` returns the raw text unchanged. Chinese date formats such as `2026年4月21日` (common in this domain) or numeric Excel serial dates (e.g. `46127`) will therefore be returned as `checkInDate`/`checkOutDate`, producing invalid date strings that only fail later during validation with a confusing value. Consider returning `''` for unparseable values or extending the regex to cover `YYYY年M月D日` and serial numbers.","suggestion_code":" const matched = text.match(/(\\d{4})[/\\-.]\\d{1,2}[/\\-.]\\d{1,2}/);\n if (!matched) {\n const cnMatched = text.match(/(\\d{4})年(\\d{1,2})月(\\d{1,2})日/);\n if (!cnMatched) return '';\n return `${cnMatched[1]}-${cnMatched[2].padStart(2, '0')}-${cnMatched[3].padStart(2, '0')}`;\n }","existing_code":" const matched = text.match(/(\\d{4})[/\\-.]\\d{1,2}[/\\-.]\\d{1,2}/);\n if (!matched) return text;"}
{"path":"apps/server/src/occupancies/occupancy-import-template.ts","start_line":90,"end_line":91,"category":"maintainability","severity":"low","content":"The stay-type business values (`长租`/`短租`/`long`/`short`) are hardcoded in at least three places: `normalizeStayType`, the template's data-validation list `'\"短租,长租\"'`, and the example rows. If the mapping ever changes, these locations can easily drift out of sync. Extract shared constants (e.g. `STAY_TYPE_SHORT = '短租'`, `STAY_TYPE_LONG_CODE = 'long'`) and reference them everywhere.","suggestion_code":null,"existing_code":" if (value === '长租' || value.toLowerCase() === 'long') return 'long';\n if (value === '短租' || value.toLowerCase() === 'short') return 'short';"}
{"path":"apps/server/src/occupancies/occupancy-import-template.ts","start_line":123,"end_line":125,"category":"maintainability","severity":"low","content":"The `cellText(getCell(row, key)) || undefined` pattern is repeated ~15 times in the row-building object. Extracting a small helper (e.g. `const textOf = (row, key) => cellText(getCell(row, key)) || undefined`) would remove the duplication and make the row mapping easier to read.","suggestion_code":" const textOf = (key: keyof OccupancyImportRow) => cellText(getCell(row, key)) || undefined;\n rows.push({\n roomNumber: lastRoomNumber,\n building: textOf('building'),","existing_code":" rows.push({\n roomNumber: lastRoomNumber,\n building: cellText(getCell(row, 'building')) || undefined,"}
{"path":"apps/server/src/organizations/organizations.service.ts","start_line":62,"end_line":65,"category":"bug","severity":"medium","content":"The single-host invariant is enforced with a check-then-insert that is not atomic. Two concurrent requests with `isHost: true` can both pass the `findOne({ where: { isHost: true } })` check and create two host rows (there is no unique constraint on `is_host`). This breaks the '只能配置一个本机构' invariant and makes `getHostOrganization` nondeterministic. Suggest adding a DB-level partial unique index (e.g., unique on `is_host WHERE is_host = true`) or serializing/transaction-locking the check+insert.","suggestion_code":null,"existing_code":" if (dto.isHost) {\n const existingHost = await this.repo.findOne({ where: { isHost: true } });\n if (existingHost) throw new BadRequestException('本机构已存在,只能配置一个本机构');\n }"}
{"path":"apps/server/src/organizations/organizations.service.ts","start_line":70,"end_line":70,"category":"bug","severity":"medium","content":"`dto.code.trim()` will throw a TypeError if `code` is absent (e.g., if the validation pipe is misconfigured or the service is called directly), since `code` is only guaranteed by DTO validation, not at runtime. Additionally, `code` has a DB unique constraint, so a duplicate code will surface as an unhandled raw driver error (HTTP 500) instead of a friendly 400. Recommend defensive handling: `dto.code?.trim().toUpperCase() ?? dto.code` and an explicit uniqueness check that throws `BadRequestException`.","suggestion_code":null,"existing_code":" code: dto.code.trim().toUpperCase(),"}
{"path":"apps/server/src/organizations/organizations.service.ts","start_line":86,"end_line":86,"category":"bug","severity":"medium","content":"`update` also does not check code uniqueness before writing. Since `code` is `unique: true` in the entity, updating to a code already used by another organization (or even another row with the same code) will throw a raw DB constraint error (500) instead of a clear BadRequestException. Consider an existence check excluding the current id (`findOne({ where: { code: newCode, id: Not(id) } })`) before updating.","suggestion_code":null,"existing_code":" ...(dto.code ? { code: dto.code.trim().toUpperCase() } : {}),"}
{"path":"apps/server/src/organizations/organizations.service.ts","start_line":66,"end_line":66,"category":"other","severity":"low","content":"Color assignment relies on `repo.count()` which is not atomic. Two concurrent `create` calls both observe the same count and get the same palette color, and the resulting color can also collide with an existing org. This is cosmetic, but if palette distribution matters, compute the color based on a stable key (e.g., hash of `code`) or assign it inside a transaction.","suggestion_code":null,"existing_code":" const color = dto.color || COLOR_PALETTE[(await this.repo.count()) % COLOR_PALETTE.length];"}
{"path":"apps/server/src/rbac/dto/rbac.dto.ts","start_line":11,"end_line":13,"category":"bug","severity":"medium","content":"`@IsString()` accepts empty strings, so `name: ''` passes validation (same applies to `username` in `CreateUserDto`). Add `@IsNotEmpty()` (or `@MinLength(1)`) to these required string fields to reject blank values.","suggestion_code":"export class CreateRoleDto {\n @IsString()\n @IsNotEmpty()\n name: string;","existing_code":"export class CreateRoleDto {\n @IsString()\n name: string;"}
{"path":"apps/server/src/rbac/dto/rbac.dto.ts","start_line":87,"end_line":88,"category":"bug","severity":"medium","content":"`subjects` is declared as `string[]` but has no `@IsArray()` / `@IsString({ each: true })` validation, unlike every other array field in this file. A scalar value (e.g. `subjects: 'x'`) will pass validation and can cause a runtime crash (e.g. calling `.map`/`.forEach` on a string) in the consuming service. Add the missing validators for consistency and safety.","suggestion_code":" @IsOptional()\n @IsArray()\n @ArrayUnique()\n @IsString({ each: true })\n subjects?: string[];","existing_code":" @IsOptional()\n subjects?: string[];"}
{"path":"apps/server/src/rbac/dto/rbac.dto.ts","start_line":90,"end_line":92,"category":"bug","severity":"medium","content":"`joinedAt` is only validated as a generic string, so arbitrary non-date text (e.g. `'not-a-date'`) is accepted and later `new Date(joinedAt)` calls can produce `Invalid Date`. Use `@IsDateString()` (or `@Matches` with your expected format) to enforce a valid date format.","suggestion_code":" @IsOptional()\n @IsDateString()\n joinedAt?: string;","existing_code":" @IsOptional()\n @IsString()\n joinedAt?: string;"}
{"path":"apps/server/src/rbac/dto/rbac.dto.ts","start_line":46,"end_line":50,"category":"security","severity":"medium","content":"Password policy is very weak: `@MinLength(4)` (also used in `ResetPasswordDto`) accepts trivially weak passwords. Strengthen the policy, e.g. `@MinLength(8)` combined with a complexity rule via `@Matches(...)` (uppercase/lowercase/digit), so the minimum requirement is enforced consistently for both creation and reset.","suggestion_code":" username: string;\n\n @IsString()\n @MinLength(8)\n @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$/)\n password: string;","existing_code":" username: string;\n\n @IsString()\n @MinLength(4)\n password: string;"}
{"path":"apps/server/src/rbac/dto/rbac.dto.ts","start_line":64,"end_line":66,"category":"security","severity":"low","content":"`UpdateUserDto` allows changing `username`, which is typically the login identifier. If this endpoint is not strictly admin-only, users could rename their own account without any verification. Confirm the controller/service enforces admin privilege and uniqueness checks; consider making username immutable or requiring extra verification for such changes.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsString()\n username?: string;"}
{"path":"apps/server/src/rbac/dto/rbac.dto.ts","start_line":19,"end_line":24,"category":"maintainability","severity":"low","content":"The same validation stack for `permissionIds`/`roleIds` (`@IsOptional() @IsArray() @ArrayUnique() @IsInt({ each: true }) @Min(1, { each: true })`) is duplicated 4 times across `CreateRoleDto`, `UpdateRoleDto`, `CreateUserDto`, and `UpdateUserDto`. Extract it into a shared base class or a custom validator (e.g. a reusable `@IsIdArray()` decorator) to keep rules consistent and prevent future drift.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsArray()\n @ArrayUnique()\n @IsInt({ each: true })\n @Min(1, { each: true })\n permissionIds?: number[];"}
{"path":"apps/server/src/rbac/rbac-user.service.ts","start_line":266,"end_line":267,"category":"security","severity":"high","content":"`updateTeacherProfile` returns the full `User` entity directly from `save()`. The entity contains sensitive fields such as `passwordHash` (and any other internal columns), which would be exposed in the API response if the controller returns it as-is. All other methods in this service return a sanitized shape (`{ message, profile }`). Return a DTO/sanitized object instead.","suggestion_code":" user.profile = { ...user.profile, ...profile };\n await this.userRepo.save(user);\n return { message: '资料已更新', profile: user.profile };","existing_code":" user.profile = { ...user.profile, ...profile };\n return this.userRepo.save(user);"}
{"path":"apps/server/src/rbac/rbac-user.service.ts","start_line":192,"end_line":197,"category":"bug","severity":"medium","content":"`updateUserProfile` overwrites the entire `profile` object with only the three known keys, silently discarding any other fields already stored in `profile` (e.g., phone, address, bio) whenever any of these fields is updated. Note that `updateTeacherProfile` below correctly merges with spread (`{ ...user.profile, ...profile }`), so behavior is inconsistent. Merge with the existing profile instead of replacing it.","suggestion_code":" user.profile = { ...current };\n if (dto.subjects !== undefined) user.profile.subjects = dto.subjects;\n if (dto.joinedAt !== undefined) user.profile.joinedAt = dto.joinedAt;\n if (dto.qualifications !== undefined) user.profile.qualifications = dto.qualifications;","existing_code":" user.profile = {\n subjects: dto.subjects !== undefined ? dto.subjects : current.subjects,\n joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt,\n qualifications:\n dto.qualifications !== undefined ? dto.qualifications : current.qualifications,\n };"}
{"path":"apps/server/src/rbac/rbac-user.service.ts","start_line":206,"end_line":207,"category":"maintainability","severity":"medium","content":"Business values are hardcoded here (role code `'teacher'` and role names `'任课老师'`/`'老师'`). This couples the query to data that may change in the DB, and the same pattern is repeated (`'staff'`/`'active'` in markAsStaff/markAsStudent, `'admin'` in archive/purge). Extract these into named constants (e.g., an enum or a constants module) so they stay consistent with the seed data.","suggestion_code":null,"existing_code":" const teacherRoleCodes = ['teacher'];\n const teacherRoleNames = ['任课老师', '老师'];"}
{"path":"apps/server/src/rbac/rbac-user.service.ts","start_line":26,"end_line":26,"category":"maintainability","severity":"low","content":"`findByIds` is deprecated in TypeORM and was removed in newer versions (use `find({ where: { id: In(ids) } })`). The same pattern is used in both `resolvePermissions` and `resolveRoles`. `In` is already imported in this file.","suggestion_code":" const permissions = uniqueIds.length > 0 ? await this.permRepo.find({ where: { id: In(uniqueIds) } }) : [];","existing_code":" const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : [];"}
{"path":"apps/server/src/rbac/rbac-user.service.ts","start_line":35,"end_line":35,"category":"maintainability","severity":"low","content":"`resolvePermissions` and `resolveRoles` are near-identical (dedupe IDs, look up by IDs, validate missing IDs, throw). Extract a shared generic resolver, e.g. `resolveByIds<T extends { id: number }>(repo, ids, errorLabel)`, to avoid duplicated logic and keep the missing-ID message format consistent.","suggestion_code":null,"existing_code":" private async resolveRoles(roleIds: number[]): Promise<Role[]> {"}
{"path":"apps/server/src/rbac/rbac-user.service.ts","start_line":161,"end_line":161,"category":"maintainability","severity":"low","content":"Hardcoded business status values (`'staff'`/`'active'`) are duplicated across markAsStaff/markAsStudent and must match DB seed data. Extract into shared constants to prevent drift and typos.","suggestion_code":null,"existing_code":" await this.studentRepo.update(student.id, { status: 'staff' });"}
{"path":"apps/server/src/operation-logs/dto/operation-log.dto.ts","start_line":26,"end_line":29,"category":"maintainability","severity":"low","content":"Duplicate code: `startDate`/`endDate` share the identical decorator stack (`@IsOptional` + `@Matches(/^\\d{4}-\\d{2}-\\d{2}$/)` + `@IsISO8601({ strict: true })`), and `page`/`pageSize` repeat `@Type(() => Number)` + `@IsInt()` + `@Min(1)` (plus `@Max`). Consider extracting a shared custom decorator (e.g. `@IsDateOnlyString()`) and/or a base pagination DTO so the constraints stay consistent and don't drift when rules change.","suggestion_code":null,"existing_code":" @IsOptional()\n @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601({ strict: true })\n startDate?: string;"}
{"path":"apps/server/src/operation-logs/dto/operation-log.dto.ts","start_line":31,"end_line":34,"category":"bug","severity":"low","content":"No cross-field validation ensures `endDate >= startDate` at the DTO layer; a reversed range currently only fails later inside `OperationLogsService.findAll` (which throws BadRequestException). Adding a `@Validate` custom constraint here would make the API contract self-documenting and provide fail-fast defense-in-depth. Low severity because the service already guards this path (and it is covered by a spec test).","suggestion_code":null,"existing_code":" @IsOptional()\n @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601({ strict: true })\n endDate?: string;"}
{"path":"apps/server/src/operation-logs/dto/operation-log.dto.ts","start_line":61,"end_line":63,"category":"maintainability","severity":"low","content":"Inconsistency: `CreateAuditLogDto.targetId` uses `@IsInt()` without `@Type(() => Number)`, unlike `userId` in `QueryOperationLogsDto`. JSON `@Body()` payloads arrive as numbers so it passes today, but form/urlencoded payloads or stringified ids (e.g. `\"123\"`) would be rejected with a 400. Add `@Type(() => Number)` for consistency and robustness if non-JSON clients are possible.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsInt()\n targetId?: number;"}
{"path":"apps/server/src/rbac/rbac.module.ts","start_line":18,"end_line":20,"category":"bug","severity":"medium","content":"No error handling around seedData() in onModuleInit. If the seed operation throws (e.g., DB temporarily unavailable, schema/constraint violation, duplicate key), the exception propagates out of the lifecycle hook and aborts the entire application bootstrap. If fail-fast is intended, add an explicit comment/log; otherwise wrap in try/catch, log the failure with Logger, and decide whether to swallow or rethrow so a transient seed failure doesn't take the whole service down.","suggestion_code":" async onModuleInit() {\n try {\n await this.rbacService.seedData();\n } catch (err) {\n this.logger.error('RBAC seed failed during module init', (err as Error)?.stack);\n throw err; // or swallow if seeding is non-critical\n }\n }","existing_code":" async onModuleInit() {\n await this.rbacService.seedData();\n }"}
{"path":"apps/server/src/rbac/rbac.module.ts","start_line":0,"end_line":0,"category":"maintainability","severity":"low","content":"StudentDingMapping is registered in TypeOrmModule.forFeature but no provider in this module (RbacService, RbacSeedService, RbacUserService) injects its repository — a codebase-wide search shows the entity is only referenced here within the rbac folder. Remove it from the import and forFeature list to avoid registering an unnecessary repository.","suggestion_code":"[Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, AttendanceSession]","existing_code":"[Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping, AttendanceSession]"}
{"path":"apps/server/src/rbac/rbac.module.ts","start_line":8,"end_line":8,"category":"maintainability","severity":"low","content":"Circular dependency: RbacModule imports AuthModule (via forwardRef) and AuthModule imports RbacModule back (also via forwardRef). This mutual dependency is a maintainability/init-order smell that can produce subtle resolution-order bugs and makes the module graph harder to reason about. Consider whether RbacModule really needs AuthModule, or extract the shared dependency (e.g., the guard/service that causes the cycle) into a separate module imported by both.","suggestion_code":null,"existing_code":"import { AuthModule } from '../auth/auth.module';"}
{"path":"apps/server/src/rbac/rbac-presets.ts","start_line":146,"end_line":149,"category":"maintainability","severity":"low","content":"The `import dayjs` statement is placed in the middle of the file (after the deprecated-permission declarations), and the `getChinaDateParts` date helper is unrelated to the RBAC preset catalog that this module is supposed to contain. This mixes concerns: every consumer importing `PRESET_ROLES`/`PRESET_PERMISSIONS` also pulls in `dayjs`, and imports buried mid-file are easy to miss. Suggest moving the import to the top of the file and relocating the date helper (and its tests) to a shared date utility module (e.g. `src/common/date.ts`), keeping this file purely declarative.","suggestion_code":null,"existing_code":"export const DEPRECATED_PERMISSION_CODE_SET = new Set<string>(DEPRECATED_PERMISSION_CODES);\nimport dayjs from '../common/dayjs';\n\nexport function getChinaDateParts(date = new Date()): { date: string; weekDay: number } {"}
{"path":"apps/server/src/rbac/rbac-presets.ts","start_line":54,"end_line":55,"category":"security","severity":"low","content":"`log:create`写入操作日志is exposed as an assignable permission in the permission catalog. Operation logs are normally written only by the system itself as an audit trail; granting this as a user/role permission would allow a privileged role to forge or tamper with audit records. No preset role currently references it, but since it is assignable via the catalog it remains a latent audit-integrity risk. Consider removing it from `PRESET_PERMISSIONS` (and handling log writes internally) or, if it must exist, documenting why it is safe to assign.","suggestion_code":null,"existing_code":" { code: 'log:view', name: '查看操作日志', group: 'log' },\n { code: 'log:create', name: '写入操作日志', group: 'log' },"}
{"path":"apps/server/src/rooms/dto/bed.dto.ts","start_line":7,"end_line":9,"category":"bug","severity":"medium","content":"Inconsistent validation: `CreateBedDto.status` only checks `IsString`, so a create request can set arbitrary status values (e.g. 'foo'), while `UpdateBedDto.status` restricts to the enum. This lets invalid statuses enter the system on creation and then block updates, or simply creates data-integrity inconsistencies. Apply the same `@IsEnum` (ideally backed by a shared enum/constant) here as well.","suggestion_code":" @IsOptional()\n @IsEnum(BedStatus)\n status?: BedStatus;","existing_code":" @IsOptional()\n @IsString()\n status?: string;"}
{"path":"apps/server/src/rooms/dto/bed.dto.ts","start_line":21,"end_line":23,"category":"maintainability","severity":"medium","content":"The status values are hardcoded business strings in the decorator, and `status` is typed as plain `string` rather than the constrained values. If the valid status set changes, this inline array is the only place to update, and it can easily drift from `CreateBedDto` and any service-layer logic. Extract a shared enum/union (e.g. `export enum BedStatus { Available='available', Occupied='occupied', Maintenance='maintenance' }` or `export const BED_STATUSES = [...] as const`) in a constants file and reuse it in both DTOs.","suggestion_code":" @IsOptional()\n @IsEnum(BedStatus)\n status?: BedStatus;","existing_code":" @IsOptional()\n @IsEnum(['available', 'occupied', 'maintenance'])\n status?: string;"}
{"path":"apps/server/src/rooms/dto/bed.dto.ts","start_line":31,"end_line":33,"category":"security","severity":"low","content":"`count` is only bounded by `@Min(1)` with no upper limit. A batch create endpoint backed by this DTO could be invoked with a huge `count` (e.g. 1000000), causing excessive DB writes / resource exhaustion. Add a `@Max` bound (e.g. `@Max(100)`) to enforce a reasonable batch size.","suggestion_code":" @IsInt()\n @Min(1)\n @Max(100)\n count: number;","existing_code":" @IsInt()\n @Min(1)\n count: number;"}
{"path":"apps/server/src/rbac/rbac.controller.ts","start_line":59,"end_line":63,"category":"bug","severity":"medium","content":"The role is created before `logAudit` runs, and unlike every other mutation in this controller this handler has no try/catch. If audit logging fails (e.g., a DB error in the log service), the request returns a 500 even though the role was actually created — the client may retry and create duplicates. Same pattern in the other handlers where `logAudit` runs inside the try block and an audit failure is converted into a misleading 400. Suggest isolating audit logging (catch and log its error separately) so a logging failure never changes the API response for an already-succeeded mutation.","suggestion_code":null,"existing_code":" const result = await this.rbacService.createRole(dto);\n await logAudit(this.logService, req, {\n module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`,\n });\n return result;"}
{"path":"apps/server/src/rbac/rbac.controller.ts","start_line":75,"end_line":77,"category":"security","severity":"medium","content":"This catch-all pattern (repeated in deleteRole, createUser, updateUser, resetPassword, updateUserProfile, archiveUser, etc.) has two problems: (1) it misclassifies genuine server-side errors (DB exceptions, 5xx) as client `400 BadRequest`, hiding real failures from monitoring; (2) it forwards the raw internal `e.message` to the client, which can leak DB/driver internals, and if the thrown value isn't an Error the message is `undefined`, producing a `BadRequestException` with no message. Recommend logging the original error server-side and mapping only validation/domain errors to 400, returning a generic 500 otherwise.","suggestion_code":null,"existing_code":" } catch (e: unknown) {\n throw new BadRequestException((e as { message?: string })?.message);\n }"}
{"path":"apps/server/src/rbac/rbac.controller.ts","start_line":263,"end_line":265,"category":"maintainability","severity":"low","content":"`updateTeacherProfile` is inconsistent with the rest of the controller: every other mutating handler wraps the service call in try/catch and returns a friendly 400, but this one has no error handling (a failure becomes a bare 500 with an internal stack). Also, the `req` param uses an ad-hoc inline type `{ user?: ... }` instead of the `AuthenticatedRequest` interface used everywhere else — `req.user` is optional here, which is fragile for `logAudit`. Align it with the other handlers.","suggestion_code":null,"existing_code":" @Request() req: { user?: { id: number; username: string } },\n ) {\n const result = await this.rbacService.updateTeacherProfile(+id, profile);"}
{"path":"apps/server/src/rbac/rbac.controller.ts","start_line":253,"end_line":254,"category":"bug","severity":"low","content":"`page`/`pageSize` are converted with unary `+` without validation, so a non-numeric query value (e.g. `?page=abc`) yields `NaN` which is then passed to `rbacService.getTeachers`. Use `ParseIntPipe` on the query params (or validate `Number.isFinite` before conversion) to fail fast with a proper 400.","suggestion_code":null,"existing_code":" page: page ? +page : undefined,\n pageSize: pageSize ? +pageSize : undefined,"}
{"path":"apps/server/src/rbac/rbac.controller.ts","start_line":157,"end_line":159,"category":"maintainability","severity":"low","content":"State-changing operations `archiveUser`, `restoreUser`, `markAsStaff`, and `markAsStudent` perform no audit logging, while equivalent mutations in this controller (create/update/delete/purge) all write audit entries via `logAudit`. This leaves an inconsistent, incomplete audit trail for account lifecycle changes. Consider adding `logAudit` calls here too.","suggestion_code":null,"existing_code":" async archiveUser(@Param('id', ParseIntPipe) id: number) {\n try {\n return await this.rbacService.archiveUser(+id);"}
{"path":"apps/server/src/rooms/dto/locker.dto.ts","start_line":3,"end_line":9,"category":"bug","severity":"medium","content":"Validation asymmetry: `CreateLockerDto.status` only checks `@IsString()`, while `UpdateLockerDto.status` restricts values to `['available', 'occupied', 'maintenance']`. Since `createLocker` spreads the whole DTO into the entity and saves it (room-bed-locker.service.ts line 143), an arbitrary status like `'foo'` can be persisted. Downstream logic depends on exact values (`updateLocker` checks `'occupied'`/`'maintenance'`, `deleteLocker` checks `'occupied'`), so invalid statuses silently bypass these invariants. Apply the same enum validation (ideally via a shared enum/constant) as in the update DTO.","suggestion_code":"export class CreateLockerDto {\n @IsString()\n lockerNumber: string;\n\n @IsOptional()\n @IsEnum(LockerStatus)\n status?: LockerStatus;","existing_code":"export class CreateLockerDto {\n @IsString()\n lockerNumber: string;\n\n @IsOptional()\n @IsString()\n status?: string;"}
{"path":"apps/server/src/rooms/dto/locker.dto.ts","start_line":21,"end_line":23,"category":"maintainability","severity":"medium","content":"Hardcoded business strings + improper `@IsEnum` usage: the status whitelist is a bare array literal duplicated here (and mirrored in bed.dto.ts). `@IsEnum` expects an enum/object entity, and the field is typed as `string`, so there is no type safety and the allowed values are scattered across files. Define a shared enum (e.g. `export enum LockerStatus { Available = 'available', Occupied = 'occupied', Maintenance = 'maintenance' }`) and reuse it in both create and update DTOs.","suggestion_code":" @IsOptional()\n @IsEnum(LockerStatus)\n status?: LockerStatus;","existing_code":" @IsOptional()\n @IsEnum(['available', 'occupied', 'maintenance'])\n status?: string;"}
{"path":"apps/server/src/rooms/dto/locker.dto.ts","start_line":30,"end_line":34,"category":"performance","severity":"medium","content":"`count` has no upper bound: only `@Min(1)` is enforced, and `batchCreateLockers` loops `dto.count` times and saves all entities in one request (room-bed-locker.service.ts lines 185-188). A single request with a large `count` can trigger an arbitrarily large DB write. Add `@Max(...)` with a reasonable cap to bound the batch size.","suggestion_code":"export class BatchCreateLockerDto {\n @IsInt()\n @Min(1)\n @Max(100)\n count: number;\n}","existing_code":"export class BatchCreateLockerDto {\n @IsInt()\n @Min(1)\n count: number;\n}"}
{"path":"apps/server/src/rbac/rbac-seed.service.ts","start_line":142,"end_line":142,"category":"bug","severity":"high","content":"`seedData()` is invoked on every application startup (rbac.module.ts `onModuleInit`). This bulk UPDATE unconditionally re-activates ALL users with `isActive = false` on every boot, regardless of why they were disabled. Other code paths still write this field (e.g. `restoreUser` explicitly sets `isActive: true`), so any account set inactive — deliberately or by legacy/integration code — is silently re-enabled at the next restart. If this is meant as a one-time legacy migration, guard it with a migration/version flag or narrow the criteria so intentionally disabled accounts are not resurrected on every deploy.","suggestion_code":null,"existing_code":" const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true });"}
{"path":"apps/server/src/rbac/rbac-seed.service.ts","start_line":14,"end_line":15,"category":"maintainability","severity":"medium","content":"This file defines a second `RbacService` class that fully duplicates the one in `rbac.service.ts` (findAllRoles / findRoleById / getTeacherWorkspace). Only `RbacSeedService` is imported from this file, so this whole class is dead/duplicate code that can drift out of sync. Its private `resolvePermissions` is never called and additionally relies on the deprecated `findByIds` API (use `In(...)` instead). Remove the duplicate class along with its unused repository injections.","suggestion_code":null,"existing_code":"@Injectable()\nexport class RbacService {"}
{"path":"apps/server/src/rbac/rbac-seed.service.ts","start_line":278,"end_line":278,"category":"security","severity":"medium","content":"When `ADMIN_PASSWORD` is not set, a well-known default credential `admin/admin123` is silently created on every fresh database and never forced to change. In production this is a predictable default-account risk. Consider failing startup when `ADMIN_PASSWORD` is missing, or forcing a password change on first login, instead of falling back to a hardcoded default.","suggestion_code":null,"existing_code":" const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';"}
{"path":"apps/server/src/rbac/rbac-seed.service.ts","start_line":223,"end_line":223,"category":"performance","severity":"low","content":"The duplicate-role merge performs sequential `findOne` + `save` per related user inside a loop (N+1 queries). These operations are independent, so they can be parallelized with `Promise.all` to reduce startup time when many legacy users are affected (the same pattern applies to the role permission saves above).","suggestion_code":null,"existing_code":" for (const relatedUser of duplicate.users ?? []) {"}
{"path":"apps/server/src/rooms/dto/room.dto.ts","start_line":54,"end_line":56,"category":"maintainability","severity":"medium","content":"Business status values ('available', 'full', 'maintenance') are hardcoded inline and the field is typed as a generic `string`, so there is no compile-time type safety and these strings are scattered for reuse elsewhere. Extract them into a shared `RoomStatus` enum/const (e.g. `export enum RoomStatus { Available = 'available', ... }`), type `status?: RoomStatus`, and reference it here.","suggestion_code":" @IsOptional()\n @IsEnum(RoomStatus)\n status?: RoomStatus;","existing_code":" @IsOptional()\n @IsEnum(['available', 'full', 'maintenance'])\n status?: string;"}
{"path":"apps/server/src/rooms/dto/room.dto.ts","start_line":54,"end_line":64,"category":"bug","severity":"low","content":"`monthlyRate` only validates that the value is a number; a negative rate (e.g. -100) passes validation. Add a lower bound, e.g. `@Min(0)`, to prevent invalid monetary values from reaching the database.","suggestion_code":" @IsOptional()\n @IsNumber()\n @Min(0)\n monthlyRate?: number;","existing_code":" @IsOptional()\n @IsEnum(['available', 'full', 'maintenance'])\n status?: string;\n\n @IsOptional()\n @IsString()\n rentalCategory?: string;\n\n @IsOptional()\n @IsNumber()\n monthlyRate?: number;"}
{"path":"apps/server/src/rooms/dto/room.dto.ts","start_line":32,"end_line":35,"category":"maintainability","severity":"low","content":"`UpdateRoomDto` duplicates every field/validator from `CreateRoomDto`. Consider using NestJS's `PartialType` from `@nestjs/mapped-types` (or `@nestjs/swagger`) to derive the update DTO from the create DTO and avoid the duplicated declarations drifting out of sync.","suggestion_code":"export class UpdateRoomDto extends PartialType(CreateRoomDto) {\n @IsOptional()\n @IsEnum(RoomStatus)\n status?: RoomStatus;\n}","existing_code":"export class UpdateRoomDto {\n @IsOptional()\n @IsString()\n roomNumber?: string;"}
{"path":"apps/server/src/rbac/rbac.service.ts","start_line":105,"end_line":105,"category":"bug","severity":"medium","content":"Throwing a generic `Error` here results in an HTTP 500 when a client tries to disable a system role. For user-facing API failures, use a NestJS HTTP exception (e.g., `BadRequestException` from '@nestjs/common') so the client receives a proper 4xx response instead of an internal server error.","suggestion_code":" if (role.isSystem) throw new BadRequestException('系统角色不可停用');","existing_code":" if (role.isSystem) throw new Error('系统角色不可停用');"}
{"path":"apps/server/src/rbac/rbac.service.ts","start_line":156,"end_line":158,"category":"bug","severity":"medium","content":"`assignedClasses` is built from `teacherAssignments` without deduplication, while `classIds`, `todaySchedules`, and `myStudents` are deduplicated by classId. If a teacher has multiple `ClassTeacher` rows for the same class (e.g., teaching multiple subjects), the workspace returns duplicate class entries with identical classId/className/classCode, producing inconsistent payloads and duplicate class cards on the client. Dedupe by classId (e.g., group with a Map keyed by classId and merge subjects) so each class appears once.","suggestion_code":null,"existing_code":" const assignedClasses = teacherAssignments.map((t) => ({\n classId: t.classId,\n className: t.class?.name || '',"}
{"path":"apps/server/src/rbac/rbac.service.ts","start_line":169,"end_line":169,"category":"bug","severity":"medium","content":"The \"today schedules\" query is scoped only by classIds (all classes the user is assigned to), not by `cs.teacherId`. For a subject teacher (`roleType` = subject teacher, per the preset role description \"查看自己的排课、今日课程\"), this returns the entire class schedule — including other teachers' sessions — instead of only their own lessons. If the intent is to show the user's own teaching schedule, add a teacher filter (or branch by roleType for head teachers who need the full class schedule).","suggestion_code":" .where('cs.classId IN (:...classIds)', { classIds })\n .andWhere('cs.teacherId = :userId', { userId })","existing_code":" .where('cs.classId IN (:...classIds)', { classIds })"}
{"path":"apps/server/src/rbac/rbac.service.ts","start_line":178,"end_line":178,"category":"maintainability","severity":"low","content":"The business status string `'active'` is hardcoded in two queries (class schedule and class students), and the schedule query also hardcodes `cs.status = :status` with the same literal. Extract a shared constant (e.g., `export const STATUS_ACTIVE = 'active'` or a dedicated enum/constant module) so the status values stay consistent across the codebase.","suggestion_code":null,"existing_code":" where: { classId: In(classIds), status: 'active' },"}
{"path":"apps/server/src/rooms/room-number.ts","start_line":28,"end_line":34,"category":"maintainability","severity":"medium","content":"Business rules are hardcoded as magic strings: building '2' → 单人间 and building '8' → 爆改房. These are business numbers/domain rules that should be extracted to a configuration map or constants (e.g. a BUILDING_ROOM_TYPE_MAP) so they can be maintained without code changes and reused by tests.","suggestion_code":null,"existing_code":" if (bldgNum === '2') {\n roomType = '单人间';\n capacity = 1;\n } else if (bldgNum === '8') {\n roomType = '爆改房';\n capacity = 2;\n }"}
{"path":"apps/server/src/rooms/room-number.ts","start_line":37,"end_line":37,"category":"bug","severity":"medium","content":"When the input cannot be parsed (empty string, non-numeric, wrong format), the function silently returns a default '四人间' with capacity 4. The caller cannot distinguish a successful parse from a parse failure, so a malformed/empty room number will silently propagate wrong capacity/building data downstream. Consider returning a distinguishable result (e.g. empty object / null) or throwing/returning an error so callers can handle invalid input explicitly.","suggestion_code":null,"existing_code":" return { capacity: 4, roomType: '四人间' };"}
{"path":"apps/server/src/rooms/room-number.ts","start_line":14,"end_line":15,"category":"maintainability","severity":"low","content":"This NaN guard is unreachable: the room part is matched by `\\d+`, so `roomPart.charAt(0)` is always a digit and `parseInt` can never return NaN. Additionally, this floor-extraction logic is duplicated verbatim in both the family and standard branches; it should be extracted into a shared helper (e.g. `parseFloor(roomPart: string)`).","suggestion_code":null,"existing_code":" const rawFloor = parseInt(roomPart.charAt(0), 10);\n const floor = Number.isNaN(rawFloor) ? undefined : rawFloor;"}
{"path":"apps/server/src/rooms/room-number.ts","start_line":12,"end_line":12,"category":"maintainability","severity":"low","content":"Inconsistent building naming across branches: family rooms return `${X}-${Y}栋` (e.g. '3-4栋') while standard rooms return `${X}号楼` (e.g. '3号楼'). If the `building` field is used as a key for grouping/dedup, family rooms and standard rooms of the same building will never be unified. Confirm this is intentional, otherwise align the naming convention (e.g. also use `${X}号楼` with a unit field).","suggestion_code":null,"existing_code":" const bldg = `${familyMatch[1]}-${familyMatch[2]}栋`;"}
{"path":"apps/server/src/rooms/room-bed-locker.service.ts","start_line":101,"end_line":102,"category":"bug","severity":"medium","content":"Inconsistent capacity accounting: `assertCanAddBeds` counts ALL beds (including `archived` ones) via `bedRepo.count`, while `batchCreateBeds`/`batchCreateLockers` exclude archived rows. Since `deleteBed`/`deleteLocker` archive rows instead of removing them, a bed that was archived still consumes capacity here, so `createBed` will be rejected with \"床位不能超过额定人数\" even though the archived bed is no longer in use. Archive/delete should free up capacity. Filter with `status: Not('archived')` to match the batch path.","suggestion_code":" private async assertCanAddBeds(room: Room, count: number): Promise<void> {\n const existingCount = await this.bedRepo.count({ where: { roomId: room.id, status: Not('archived') } });","existing_code":" private async assertCanAddBeds(room: Room, count: number): Promise<void> {\n const existingCount = await this.bedRepo.count({ where: { roomId: room.id } });"}
{"path":"apps/server/src/rooms/room-bed-locker.service.ts","start_line":41,"end_line":43,"category":"bug","severity":"medium","content":"Check-then-insert is not atomic: the uniqueness check (`bedRepo.findOne`) and the capacity check (`assertCanAddBeds`) are both separate queries followed by `save`. Under concurrent requests, two `createBed` calls with the same `bedNumber` (or two creates that together exceed capacity) can both pass the checks and insert duplicates. Consider a DB unique constraint on (roomId, bedNumber) plus catching the duplicate-key error, or wrap the check+insert in a transaction with row locking (same applies to `createLocker`).","suggestion_code":null,"existing_code":" const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } });\n if (existing) throw new BadRequestException('该床位编号已存在');\n const bed = this.bedRepo.create({ ...dto, roomId });"}
{"path":"apps/server/src/rooms/room-bed-locker.service.ts","start_line":143,"end_line":144,"category":"bug","severity":"medium","content":"`CreateLockerDto.status` is a free-form `@IsString()` (no enum), so `createLocker` can create a locker directly in `'archived'` or `'occupied'` state, bypassing the state-machine rules enforced by `deleteLocker`/`updateLocker` (e.g., occupied-only-after-check-in). The same issue exists in `createBed` via `CreateBedDto.status`. Either validate with `@IsEnum(['available', 'maintenance'])` (excluding `occupied`/`archived`) or default the status and ignore client-supplied values.","suggestion_code":null,"existing_code":" const locker = this.lockerRepo.create({ ...dto, roomId });\n return this.lockerRepo.save(locker);"}
{"path":"apps/server/src/rooms/room-bed-locker.service.ts","start_line":80,"end_line":84,"category":"bug","severity":"low","content":"The next-number computation is duplicated inline instead of reusing the already-defined `getNextBedNumber(beds)`, and it ignores archived rows: since `deleteBed` leaves archived beds in the table (e.g. \"5号床\"), batch creation starting at `max(non-archived numbers) + 1` can generate a number that collides with an existing archived bed, and `batchCreateBeds` performs no duplicate check at all (unlike `createBed`). Reuse `getNextBedNumber` and guard against conflicts with archived bed numbers; same for `batchCreateLockers`.","suggestion_code":" const start = this.getNextBedNumber(existing);","existing_code":" const numbers = existing.map((b) => {\n const match = b.bedNumber.match(/^\\d+/);\n return match ? parseInt(match[0]) : 0;\n });\n const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1;"}
{"path":"apps/server/src/rooms/room-bed-locker.service.ts","start_line":50,"end_line":53,"category":"bug","severity":"low","content":"The guard only blocks `occupied → maintenance`; the same endpoint allows `occupied → available` directly, which skips the intended check-out flow that the comment alludes to (\"请先退宿\"). If occupied status must be cleared only through a proper checkout, this transition needs the same protection — and the corresponding `updateLocker` has the same asymmetry.","suggestion_code":null,"existing_code":" // 不允许将 occupied 的床位改为 maintenance\n if (dto.status === 'maintenance' && bed.status === 'occupied') {\n throw new BadRequestException('该床位有人入住,请先退宿');\n }"}
{"path":"apps/server/src/rooms/room-occupancy-date.ts","start_line":9,"end_line":12,"category":"bug","severity":"medium","content":"The generated conditions never restrict `status` to 'active', so archived occupancy rows are also returned as \"occupying on date\". Other occupancy queries in this codebase consistently filter `status: 'active'` (e.g. dashboard-queries.service.ts uses `o.status = 'active'` on the occupancies table). If an archived/cancelled occupancy still has `checkOutDate = NULL` or a future `checkOutDate`, it will be wrongly counted as an active occupant. Consider adding `status: 'active'` to both branches (or have callers add it).","suggestion_code":" return [\n { ...scope, status: 'active', checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() },\n { ...scope, status: 'active', checkInDate: LessThanOrEqual(date), checkOutDate: MoreThan(date) },\n ];","existing_code":" return [\n { ...scope, checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() },\n { ...scope, checkInDate: LessThanOrEqual(date), checkOutDate: MoreThan(date) },\n ];"}
{"path":"apps/server/src/rooms/room-occupancy-date.ts","start_line":8,"end_line":8,"category":"bug","severity":"low","content":"The guard only handles `undefined`, not `null`. If a caller ever passes `null` for `roomId` (e.g. from unvalidated query params), the spread produces `{ roomId: null }`, which TypeORM translates to `room_id IS NULL` and silently returns no/wrong rows instead of \"all rooms\". Handle both `undefined` and `null` defensively.","suggestion_code":"const scope = roomId === undefined || roomId === null ? {} : { roomId };","existing_code":"const scope = roomId === undefined ? {} : { roomId };"}
{"path":"apps/server/src/rooms/rooms.service.ts","start_line":85,"end_line":90,"category":"performance","severity":"medium","content":"N+1 query issue: getRoomOverview issues one COUNT query per room in a loop. For a large dorm complex this results in N+1 round trips. Prefer a single grouped query, e.g. `this.occRepo.createQueryBuilder('o').select('o.roomId', 'roomId').addSelect('COUNT(o.id)', 'count').where('o.checkOutDate IS NULL').andWhere('o.roomId IN (:...ids)', { ids: rooms.map(r => r.id) }).groupBy('o.roomId').getRawMany()`, then join the counts into the result map.","suggestion_code":null,"existing_code":" for (const room of rooms) {\n const count = await this.occRepo.count({\n where: { roomId: room.id, checkOutDate: IsNull() },\n });\n result.push({ ...room, currentCount: count });\n }"}
{"path":"apps/server/src/rooms/rooms.service.ts","start_line":103,"end_line":105,"category":"bug","severity":"medium","content":"create() is not atomic: the room is saved first, then default beds are created in a separate, non-transactional step. If createDefaultBeds fails (e.g. DB hiccup), the room is left persisted without beds. Additionally, room.roomNumber has a DB unique constraint, but there is no pre-check or unique-violation handling here, so a duplicate room number will surface as a raw 500 DB error instead of a 4xx. Wrap both operations in a transaction (e.g. `this.dataSource.transaction`) and handle the duplicate key case explicitly.","suggestion_code":null,"existing_code":" const room = await this.repo.save(entity);\n await this.createDefaultBeds(room.id, room.capacity);\n return room;"}
{"path":"apps/server/src/rooms/rooms.service.ts","start_line":61,"end_line":61,"category":"bug","severity":"low","content":"Ordering by `roomNumber` (and `building` in getRoomOverview) uses lexicographic string comparison. With room numbers like \"4-102\" and \"4-22\", or buildings like \"10号楼\" and \"2号楼\", the result order is wrong (\"102\" sorts before \"22\"). Either sort numerically in JS after fetching (parse the leading number), or use an expression-based ORDER BY in the query builder (e.g. `ORDER BY CAST(SUBSTRING_INDEX(roomNumber,'-',-1) AS UNSIGNED)`).","suggestion_code":null,"existing_code":" return this.repo.find({ where, order: { roomNumber: 'ASC' } });"}
{"path":"apps/server/src/rooms/rooms.service.ts","start_line":180,"end_line":182,"category":"bug","severity":"low","content":"TOCTOU race in batchRemove/batchPurge: the active-occupancy check and the status update / delete are not performed in a transaction. A student could check in (occupancy inserted) between the `count` check and the archive update, leaving an archived room with active occupants. The per-room checks are also run sequentially; they could be batched with a single `IN` + `GROUP BY` query and executed inside one transaction (or with pessimistic locking) to keep check-and-act consistent.","suggestion_code":null,"existing_code":" const activeCount = await this.occRepo.count({\n where: { roomId: r.id, checkOutDate: IsNull() },\n });"}
{"path":"apps/server/src/rooms/room-query.service.ts","start_line":273,"end_line":277,"category":"performance","severity":"medium","content":"batchImport performs two sequential DB round-trips per row (findOne + save) in a for-await loop, i.e. O(2N) queries for a bulk import. Rows are independent, so this is needlessly slow for large files. Suggest de-duplicating roomNumbers within the batch, checking existing rooms with a single `In(...)` query, then inserting the remainder with a transaction / Promise.all.","suggestion_code":null,"existing_code":" const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } });\n if (exists) {\n skipped++;\n continue;\n }"}
{"path":"apps/server/src/rooms/room-query.service.ts","start_line":280,"end_line":282,"category":"bug","severity":"medium","content":"batchImport is not wrapped in a transaction and has no per-row error handling: if a later row fails (e.g. unique room_number constraint hit by a concurrent import, or a bad value), the rooms already saved earlier in the loop remain persisted, leaving a partial import, and the raw DB error is thrown to the caller without a user-friendly message. Wrap the loop in `this.repo.manager.transaction(...)` (or skip-and-continue per-row on expected failures) so imports are atomic and reported cleanly.","suggestion_code":null,"existing_code":" const room = await this.repo.save(\n this.repo.create({\n roomNumber: row.roomNumber.trim(),"}
{"path":"apps/server/src/rooms/room-query.service.ts","start_line":97,"end_line":97,"category":"bug","severity":"low","content":"checkOutDate boundary is inconsistent with the shared occupancyWhereOnDate helper (which uses strict `MoreThan(date)`): here `o.checkOutDate >= :date` still counts an occupant who checked out on the target date as occupied, while getRoomVisual/occupancyWhereOnDate treats them as vacated that day. The two endpoints can report different occupancy counts for the same room/date. Align both to the same boundary (e.g. `>`).","suggestion_code":null,"existing_code":" .andWhere('(o.checkOutDate IS NULL OR o.checkOutDate >= :date)', { date: targetDate });"}
{"path":"apps/server/src/rooms/room-query.service.ts","start_line":83,"end_line":83,"category":"style","severity":"low","content":"Uses loose `== null` (also on `row.room_floor` below), which violates the project rule requiring strict equality. Since getRawMany returns SQL NULL as `null`, `=== null` is safe here; alternatively use `String(row.room_building ?? '')` / `Number(row.room_floor ?? 0)`.","suggestion_code":null,"existing_code":" building: row.room_building == null ? null : String(row.room_building),"}
{"path":"apps/server/src/rooms/room-query.service.ts","start_line":160,"end_line":160,"category":"bug","severity":"low","content":"`buildings` is computed from all `rooms` before archived-room filtering. In the historical view, an archived room with no occupants on the target date is hidden from `visibleRooms`, yet its building can still appear in the returned dropdown with no visible rooms behind it. Derive the list from `visibleRooms` instead.","suggestion_code":null,"existing_code":" const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))];"}
{"path":"apps/server/src/rooms/rooms.controller.ts","start_line":358,"end_line":358,"category":"security","severity":"medium","content":"No upload size limit on FileInterceptor, and the entire file is loaded into memory via `file.buffer` then copied again into an ArrayBuffer (`bufferToArrayBuffer`). An authenticated user can upload an arbitrarily large file, doubling memory usage and exhausting server memory (DoS). Add a `limits: { fileSize }` to the interceptor and/or stream the file instead of buffering it whole.","suggestion_code":" @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 } }))","existing_code":" @UseInterceptors(FileInterceptor('file'))"}
{"path":"apps/server/src/rooms/rooms.controller.ts","start_line":362,"end_line":362,"category":"bug","severity":"medium","content":"`file` is not null-checked: if the request omits the `file` field (or uses the wrong field name), `@UploadedFile()` yields `undefined` and `file.buffer` throws an unhandled TypeError, producing a generic 500. Guard against a missing file and return a user-friendly 400 error instead.","suggestion_code":" if (!file) {\n throw new BadRequestException('请上传 Excel 文件');\n }\n const workbook = new ExcelJS.Workbook();\n await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));","existing_code":" await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));"}
{"path":"apps/server/src/rooms/rooms.controller.ts","start_line":388,"end_line":388,"category":"bug","severity":"medium","content":"`Number(...) || 4` silently coerces an empty cell, `0`, `'0'`, or any non-numeric string into the default capacity 4. Imported data is thus silently corrupted (e.g. a room with 0 seats or a typo like '1O' becomes a 4-person room). Validate the cell explicitly (finite and > 0) instead of relying on truthiness.","suggestion_code":" capacity: (n => (Number.isFinite(n) && n > 0 ? n : 4))(Number(row.getCell(4).value)),","existing_code":" capacity: Number(row.getCell(4).value) || 4,"}
{"path":"apps/server/src/rooms/rooms.controller.ts","start_line":318,"end_line":319,"category":"bug","severity":"medium","content":"`batchRemove` (and likewise `batchPurge`) takes an unvalidated `@Body() body: { ids: number[] }`. If `ids` is missing, a string, or a non-array value, `body.ids || []` passes garbage to the service and `(body.ids || []).join(',')` throws a TypeError (e.g. for a string `ids`) or a 500. This is also inconsistent with `batchRestore`, which uses `BatchIdsDto` with a strict ValidationPipe — reuse the same validated DTO here.","suggestion_code":" @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))\n async batchRemove(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {\n const result = await this.service.batchRemove(dto.ids);","existing_code":" async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {\n const result = await this.service.batchRemove(body.ids || []);"}
{"path":"apps/server/src/rooms/rooms.controller.ts","start_line":154,"end_line":154,"category":"maintainability","severity":"low","content":"Using `@Res() res?: Response` with manual `res.setHeader`/`res.write`/`res.end()` bypasses Nest's response lifecycle. If `workbook.xlsx.write(res)` throws after headers are sent, the response is never ended and the client hangs; also `res!` non-null assertions suggest the optional param is really always present. Prefer streaming the workbook and relying on Nest's exception handling, or at minimum wrap the write in try/finally to guarantee `res.end()`.","suggestion_code":null,"existing_code":" async exportExcel(@Query('includeArchived') includeArchived?: string, @Res() res?: Response) {"}
{"path":"apps/server/src/rooms/rooms.controller.ts","start_line":384,"end_line":385,"category":"bug","severity":"low","content":"Import rows are pushed without any filtering: `ws.eachRow` iterates rows inside the sheet's used range, so blank rows (or rows left over from deleted data) produce entries with an empty `roomNumber: ''` that get sent to `batchImport`, risking garbage records or a confusing import failure. Skip rows whose room number is empty/whitespace before collecting.","suggestion_code":" const roomNumber = cellValueText(row.getCell(1).value).trim();\n if (!roomNumber) return;\n rows.push({\n roomNumber,","existing_code":" rows.push({\n roomNumber: cellValueText(row.getCell(1).value),"}
{"path":"apps/server/src/schedules/dto/schedule.dto.ts","start_line":35,"end_line":37,"category":"bug","severity":"medium","content":"Missing cross-field validation: nothing enforces that `endTime` is later than `startTime` (nor that `endDate` >= `startDate`). A request like startTime \"18:00\" / endTime \"09:00\" or an endDate before startDate will pass validation and produce an invalid schedule. Consider a custom validator (or @ValidateIf-based check) to ensure time/date ranges are consistent.","suggestion_code":null,"existing_code":" @IsMilitaryTime()\n @IsNotEmpty()\n endTime: string;"}
{"path":"apps/server/src/schedules/dto/schedule.dto.ts","start_line":78,"end_line":81,"category":"bug","severity":"medium","content":"Every field in UpdateScheduleDto is optional, so an empty payload `{}` passes validation and reaches the service. This can silently become a no-op or, depending on the update implementation, unintentionally clear/overwrite fields. Consider rejecting empty updates, e.g. add `@IsNotEmptyObject()` on the DTO or validate that at least one field is present.","suggestion_code":null,"existing_code":"export class UpdateScheduleDto {\n @IsOptional()\n @IsInt()\n classId?: number;"}
{"path":"apps/server/src/schedules/dto/schedule.dto.ts","start_line":63,"end_line":65,"category":"maintainability","severity":"low","content":"`scheduleType` is a free-form string with no constraint, unlike `status` which is validated with @IsIn. Any typo (e.g. \"Recurring\" vs \"recurring\") is silently accepted and stored. If this field has a known set of allowed values, validate it with @IsIn(['...']) for consistency with `status`; otherwise a max length / pattern check would at least bound the input.","suggestion_code":null,"existing_code":" @IsOptional()\n @IsString()\n scheduleType?: string;"}
{"path":"apps/server/src/schedules/schedules.controller.ts","start_line":90,"end_line":90,"category":"security","severity":"high","content":"Authorization bypass: in schedules.service.ts, `findAll` only applies the `accessibleClassIds` restriction in the `else if` branch — when `query.classId` is provided, the accessible-class filter is skipped entirely. A teacher with only `schedule:view` can therefore pass an arbitrary `classId` and read schedules of classes they are not assigned to. Validate that `query.classId` is within the computed `classIds` here (and similarly for other handlers that forward a `classId` filter).","suggestion_code":" if (query.classId !== undefined && classIds && !classIds.includes(query.classId)) {\n return [];\n }\n return this.service.findAll(query, classIds);","existing_code":" return this.service.findAll(query, classIds);"}
{"path":"apps/server/src/schedules/schedules.controller.ts","start_line":174,"end_line":174,"category":"bug","severity":"medium","content":"Dead code / broken conflict notification: `this.service.checkConflict()` throws a `ConflictException` whenever a conflict is found (schedules.service.ts lines ~318 and ~333), so re-invoking it inside this catch will throw again and be silently swallowed by the empty `catch {}` — `notifyScheduleConflict` is effectively unreachable and affected teachers are never notified. Provide a non-throwing variant (e.g., `findConflicts(...)`) that returns the conflicting schedules, or derive the conflicts from the thrown exception.","suggestion_code":null,"existing_code":" this.notifyScheduleConflict(conflicts, dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, '');"}
{"path":"apps/server/src/schedules/schedules.controller.ts","start_line":233,"end_line":235,"category":"bug","severity":"medium","content":"Wrong values used for the conflict re-check after a failed update: the check uses `existing.*` (the pre-update stored values) instead of the new values proposed in the DTO. Since the update failed precisely because the NEW values conflict, checking the old values will not surface the actual conflicting schedule, and the notification would report the wrong classroom/time. Use `dto.classroomId ?? existing.classroomId` / `dto.weekDay ?? existing.weekDay` / `dto.startTime ?? existing.startTime` etc., mirroring the fallback logic already used in schedules.service.ts `update()`. Note also that `checkConflict` throws when conflicts exist, so it will be swallowed here — use a non-throwing query instead.","suggestion_code":null,"existing_code":" const conflicts = await this.service.checkConflict(\n existing.classroomId,\n existing.weekDay,"}
{"path":"apps/server/src/schedules/schedules.controller.ts","start_line":63,"end_line":63,"category":"style","severity":"low","content":"Loose equality `==` is prohibited by the review rules; use strict equality. `schedule.classId == null` matches both `null` and `undefined` — express that explicitly.","suggestion_code":" if (schedule.classId === null || schedule.classId === undefined) {","existing_code":" if (schedule.classId == null) {"}
{"path":"apps/server/src/schedules/schedules.controller.ts","start_line":76,"end_line":79,"category":"maintainability","severity":"low","content":"This `getAccessibleClassIds(req.user.id, this.canManageAllSchedules(req))` pattern is duplicated in 5 handlers (getLookups, findAll, getWeeklyView, getClassTeachers, getClassroomOccupancy). Extract a private helper (e.g., `private getAccessibleClassIds(req: { user: RequestUser })`) to avoid repetition and keep the authorization logic consistent.","suggestion_code":null,"existing_code":" const classIds = await this.service.getAccessibleClassIds(\n req.user.id,\n this.canManageAllSchedules(req),\n );"}
{"path":"apps/server/src/students/dto/student.dto.ts","start_line":4,"end_line":6,"category":"maintainability","severity":"medium","content":"CreateStudentDto and UpdateStudentDto duplicate nearly all field definitions (name, studentNo, phone, idNumber, gender, ethnicity, emergencyContact, emergencyPhone, organizationId, supervisor). If a field is added/renamed in one DTO but forgotten in the other, validations silently diverge. Consider extracting a shared base class (e.g., `StudentBaseDto`) and having UpdateStudentDto extend it, or use `PartialType(CreateStudentDto)` from `@nestjs/mapped-types`/`@nestjs/swagger` to keep the two in sync.","suggestion_code":null,"existing_code":"export class CreateStudentDto {\n @IsString()\n name: string;"}
{"path":"apps/server/src/students/dto/student.dto.ts","start_line":36,"end_line":37,"category":"bug","severity":"low","content":"`organizationId` lacks `@Type(() => Number)` here (and in UpdateStudentDto), unlike QueryStudentDto which applies it. When the payload arrives as a string (form-urlencoded bodies, multipart, or proxies that serialize numbers to strings), `@IsInt()` will reject a valid numeric value like \"5\". Add `@Type(() => Number)` for consistent and reliable type coercion.","suggestion_code":" @Type(() => Number)\n @IsInt()\n organizationId: number;","existing_code":" @IsInt()\n organizationId: number;"}
{"path":"apps/server/src/students/dto/student.dto.ts","start_line":100,"end_line":105,"category":"maintainability","severity":"low","content":"The fallback `return value;` in the boolean transform passes unrecognized inputs (e.g., \"TRUE\", \"Yes\", \"\") straight to `@IsBoolean()`, which rejects them with a generic, hard-to-debug 400 message. Normalize case-insensitively (e.g., also accept 'TRUE'/'yes') and/or throw a descriptive error (or return undefined) for invalid values so callers understand which query parameter was malformed.","suggestion_code":null,"existing_code":" @Transform(({ value }: { value: unknown }) => {\n if (typeof value === 'boolean') return value;\n if (value === 'true' || value === '1') return true;\n if (value === 'false' || value === '0') return false;\n return value;\n })"}
{"path":"apps/server/src/schedules/schedule-queries.service.ts","start_line":87,"end_line":87,"category":"maintainability","severity":"low","content":"The status literal `'active'` is hardcoded here even though the `ACTIVE_SCHEDULE_STATUS` constant defined at the top of this file is already used by `getWeeklyView` and `getClassroomOccupancy`. Reuse the constant to keep the status value in one place and avoid divergence.","suggestion_code":".where('cs.status = :active', { active: ACTIVE_SCHEDULE_STATUS });","existing_code":".where('cs.status = :active', { active: 'active' });"}
{"path":"apps/server/src/schedules/schedule-queries.service.ts","start_line":109,"end_line":109,"category":"style","severity":"low","content":"Project rules require strict equality (`===`/`!==`); `==`/`!=` are prohibited. Here `== null` is used in several places (`cs_class_id`, `class_name`, `classroom_name`, `teacher_name`). Since raw query values can also be `undefined`, use an explicit null/undefined check.","suggestion_code":"classId: row.cs_class_id === null || row.cs_class_id === undefined ? null : Number(row.cs_class_id),","existing_code":"classId: row.cs_class_id == null ? null : Number(row.cs_class_id),"}
{"path":"apps/server/src/schedules/schedule-queries.service.ts","start_line":102,"end_line":103,"category":"maintainability","severity":"low","content":"All three public async methods (`agentSearchSchedules`, `getWeeklyView`, `getClassroomOccupancy`) execute DB queries without any error handling (try/catch). If the surrounding code does not rely on a global exception filter, DB failures will surface as raw 500s without a user-friendly message. Consider centralizing error handling or adding try/catch that maps failures to a friendly error.","suggestion_code":null,"existing_code":"const rows = await qb\n .orderBy('cs.weekDay', 'ASC')"}
{"path":"apps/server/src/students/student-access-scope.factory.ts","start_line":34,"end_line":46,"category":"maintainability","severity":"medium","content":"Duplicate logic: this ability construction plus the `isSuperAdmin || can(Update, Class)` condition duplicates `AgentBusinessScopeFactory.canManageAllClasses` in agent-tools/agent-business-scope.factory.ts (which is also the condition used by the HTTP layer in students.controller.ts). Keeping the same rule in two factories risks silent divergence — e.g. if one adds an extra permission condition, the agent student scope and business scope will disagree. Consider injecting/reusing `AgentBusinessScopeFactory` (or extracting a shared helper) so the `manageAll` decision is defined in one place.","suggestion_code":" const ability = this.abilityFactory.createForUser({\n permissions: context.permissions,\n isSuperAdmin: context.isSuperAdmin,\n });\n\n // class:edit (Update Class) or super admin grants full student scope\n if (context.isSuperAdmin || ability.can(CaslAction.Update, SubjectName.Class)) {\n return { type: 'manageAll' };\n }","existing_code":" const ability = this.abilityFactory.createForUser({\n permissions: context.permissions,\n isSuperAdmin: context.isSuperAdmin,\n });\n\n if (context.isSuperAdmin) {\n return { type: 'manageAll' };\n }\n\n // class:edit (Update Class) grants full student scope\n if (ability.can(CaslAction.Update, SubjectName.Class)) {\n return { type: 'manageAll' };\n }"}
{"path":"apps/server/src/students/student-access-scope.factory.ts","start_line":39,"end_line":44,"category":"maintainability","severity":"low","content":"The explicit `context.isSuperAdmin` branch is redundant: `CaslAbilityFactory.createForUser` already grants `manage all` when `isSuperAdmin` is true, so `ability.can(CaslAction.Update, SubjectName.Class)` below would also be true and return `manageAll`. Additionally, the ability is constructed before the super-admin early return, so the branch doesn't even save the ability build. Keep it only as an intentional readability choice, or collapse it into the ability check; otherwise readers may assume it handles a case the CASL ability doesn't already cover.","suggestion_code":null,"existing_code":" if (context.isSuperAdmin) {\n return { type: 'manageAll' };\n }\n\n // class:edit (Update Class) grants full student scope\n if (ability.can(CaslAction.Update, SubjectName.Class)) {"}
{"path":"apps/server/src/students/students.import.service.ts","start_line":44,"end_line":48,"category":"bug","severity":"high","content":"`batchImport` performs multiple DB writes per row (existence check, student insert, archive imports) with no try/catch and no transaction. A single bad row (e.g., unique-constraint violation on studentNo/phone/idNumber, or an invalid archive row) aborts the entire import with a raw DB error, while rows already processed remain partially committed. Consider wrapping the import in a transaction and handling per-row errors (collect failures and continue) so one bad row doesn't discard the whole workbook.","suggestion_code":null,"existing_code":" const exists = await this.repo.findOne({ where: { name: row.name.trim() } });\n if (exists) {\n skipped++;\n continue;\n }"}
{"path":"apps/server/src/students/students.import.service.ts","start_line":243,"end_line":247,"category":"bug","severity":"medium","content":"The manual find-then-save upsert pattern (enrollment/examScore/learningRecord, and the name-based existence check in batchImport) is racy: two concurrent imports can both pass the existence check and insert duplicate rows. Use DB unique constraints together with `repo.upsert()`/`ON CONFLICT`, or serialize imports. Also note batchImport deduplicates purely by `name`, so a legitimate second student with the same name as an existing one is silently skipped rather than imported.","suggestion_code":null,"existing_code":" const existing = await this.enrollmentRepo.find({ where: { studentId } });\n const entity =\n existing.find(\n (item) =>\n this.sameValue(item.courseCategory, row.courseCategory) &&"}
{"path":"apps/server/src/students/students.import.service.ts","start_line":186,"end_line":188,"category":"performance","severity":"medium","content":"Sequential per-row DB round trips create an N+1 pattern: each student incurs `findOne` + `save` + several archive queries awaited one after another, and `data.enrollments/examScores/learningRecords` are re-filtered for every student (O(rows × students)). `getHostOrganizationId` is also re-queried for every row lacking an organizationId. Consider pre-indexing the workbook arrays by normalized phone once, caching the host organization, and processing independent rows with bounded-concurrency `Promise.all`.","suggestion_code":null,"existing_code":" for (const enrollmentRow of data.enrollments.filter(\n (item) => this.normalizePhone(item.phone) === phone,\n )) {"}
{"path":"apps/server/src/students/students.import.service.ts","start_line":260,"end_line":260,"category":"maintainability","severity":"low","content":"The business status value `'active'` is hardcoded in multiple methods (enrollment, exam score, learning record). Extract a named constant (e.g. `ENROLLMENT_STATUS_ACTIVE`) so the status stays consistent and typo-safe.","suggestion_code":null,"existing_code":" else if (!entity.status) entity.status = 'active';"}
{"path":"apps/server/src/students/students.import.service.ts","start_line":106,"end_line":109,"category":"bug","severity":"medium","content":"matchImport blindly overwrites every non-empty row field onto the matched student without cross-checking identifiers. If a row's phone matches student A while its idNumber/studentNo belongs to student B (or vice versa), A gets corrupted with B's data silently. Before applying updates, verify that all provided identifiers (phone, idNumber, studentNo) are consistent with the matched student, or skip/reject rows where they conflict.","suggestion_code":null,"existing_code":" if (row.name?.trim()) updates.name = row.name.trim();\n if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();\n if (row.phone?.trim()) updates.phone = row.phone.trim();\n if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();"}
{"path":"apps/server/src/schedules/schedules.service.ts","start_line":103,"end_line":104,"category":"security","severity":"high","content":"Authorization bypass in `findAll`: when a caller supplies `query.classId`, the `accessibleClassIds` scope is skipped entirely (the `else if` branch never runs). The controller passes the teacher's accessible class IDs straight through without validating that `query.classId` belongs to the user, so a restricted teacher can list schedules of any class by passing its id — unlike `schedule-queries.service.ts` which rejects `classId` outside the accessible set. Enforce the intersection here, e.g. return `[]` when `accessibleClassIds` is defined and does not contain `query.classId`.","suggestion_code":" if (query.classId) {\n if (accessibleClassIds && !accessibleClassIds.includes(query.classId)) return [];\n qb.andWhere('cs.classId = :classId', { classId: query.classId });\n } else if (accessibleClassIds) {","existing_code":" if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId });\n else if (accessibleClassIds) {"}
{"path":"apps/server/src/schedules/schedules.service.ts","start_line":317,"end_line":318,"category":"bug","severity":"medium","content":"Check-then-act race condition: `checkConflict` runs a read query and the insert/update (`save` / `update`) happens afterwards with no transaction, lock, or DB constraint. Two concurrent create/update requests for the same classroom/time slot can both pass the conflict check and persist overlapping schedules (double booking). Consider wrapping the conflict check + write in a transaction with `PESSIMISTIC_WRITE` locking (e.g. on the classroom row) or adding a DB constraint to make reservation atomic.","suggestion_code":null,"existing_code":" const conflicts = await qb.getMany();\n if (conflicts.length > 0) {"}
{"path":"apps/server/src/schedules/schedules.service.ts","start_line":142,"end_line":142,"category":"maintainability","severity":"low","content":"Hardcoded business values: `roleType: 'subject_teacher'` (and the rental `status: 'active'` filter inside `checkConflict`) are business literals. The file already defines schedule status constants and the codebase defines a `ClassroomRentalStatus` enum, but these strings are free-floating literals that can silently drift. Extract them into shared constants/enums so typos are caught by the compiler.","suggestion_code":null,"existing_code":" where: { classId: dto.classId, roleType: 'subject_teacher', subject: dto.subject },"}
{"path":"apps/server/src/schedules/schedules.service.ts","start_line":55,"end_line":56,"category":"maintainability","severity":"low","content":"`attendanceSessionRepo` is injected in the constructor but never used anywhere in this service — remove this unused dependency.","suggestion_code":null,"existing_code":" @InjectRepository(AttendanceSession)\n private readonly attendanceSessionRepo: Repository<AttendanceSession>,"}
{"path":"apps/server/src/schedules/schedules.service.ts","start_line":339,"end_line":339,"category":"maintainability","severity":"low","content":"`checkConflict` always throws a `ConflictException` when `conflicts` is non-empty, so the trailing `return conflicts` can only ever be an empty array, and no caller uses the return value (the controller re-invokes the method and discards the result). Consider returning `void` (or `never`) to avoid a misleading API contract.","suggestion_code":null,"existing_code":" return conflicts;"}
{"path":"apps/server/src/schedules/schedules.service.ts","start_line":250,"end_line":252,"category":"maintainability","severity":"low","content":"`update` mutates the incoming `dto` object (`dto.teacherId = normalized.teacherId`), creating a side effect on the caller-owned object and making the subsequent `scheduleRepo.update(id, dto)` depend on this mutation having run first. Build a separate normalized payload instead of mutating the parameter.","suggestion_code":null,"existing_code":" if (dto.teacherId === undefined && normalized.teacherId !== undefined) {\n dto.teacherId = normalized.teacherId;\n }"}
{"path":"apps/server/src/students/students.controller.ts","start_line":257,"end_line":259,"category":"bug","severity":"medium","content":"`@UploadedFile() file` is not null-checked. If the client submits a multipart request without the `file` field, `file` is undefined and `file.buffer` throws a TypeError resulting in an opaque 500. Add a guard and return 400 (e.g. `if (!file) throw new BadRequestException('请上传文件')`, importing `BadRequestException` from '@nestjs/common'). Apply to both import endpoints.","suggestion_code":" async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {\n if (!file) {\n throw new BadRequestException('请上传文件');\n }\n const workbook = new ExcelJS.Workbook();\n await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));","existing_code":" async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {\n const workbook = new ExcelJS.Workbook();\n await workbook.xlsx.load(bufferToArrayBuffer(file.buffer));"}
{"path":"apps/server/src/students/students.controller.ts","start_line":262,"end_line":271,"category":"performance","severity":"medium","content":"This loop performs one `organizationRepo.findOne` query per row sequentially (N+1 problem). For large import files this is serialized round-trips, and duplicate organization names cause repeated identical queries. Collect the unique names first, query once with `In(...)`, build a Map<name, id>, then assign in the loop. The same block is duplicated in `matchImport` and should use the same fix.","suggestion_code":" const orgNames = [...new Set(importData.students.map((r) => r.organization).filter(Boolean))];\n const orgs = orgNames.length\n ? await this.organizationRepo.find({ where: { name: In(orgNames) } })\n : [];\n const orgMap = new Map(orgs.map((o) => [o.name, o.id]));\n for (const row of importData.students) {\n if (row.organization && orgMap.has(row.organization)) {\n row.organizationId = orgMap.get(row.organization);\n }\n }","existing_code":" for (const row of importData.students) {\n if (row.organization) {\n const organization = await this.organizationRepo.findOne({\n where: { name: row.organization },\n });\n if (organization) {\n row.organizationId = organization.id;\n }\n }\n }"}
{"path":"apps/server/src/students/students.controller.ts","start_line":163,"end_line":167,"category":"security","severity":"medium","content":"List endpoints (`findAll`, `filter-lookups`, `export`) restrict data to `getAccessibleClassIds(req.user.id, canManageAll)`, but the by-id read (`findOne`, `compareClasses`) and all mutation endpoints (`update`, `remove`, `purge`, `restore`, `batch*`) call the service without any class scope. Confirmed the service methods (`findOne(id)`, `update(id, dto)`, `remove(id)`, etc.) only take an id and do not apply scoping. A teacher holding `student:view`/`student:edit`/`student:delete`/`student:purge` can therefore read, modify, archive or permanently delete students in classes they do not teach (cross-class IDOR). Pass the accessible-class scope (or `canManageAll` flag) into these service calls and verify membership before acting.","suggestion_code":null,"existing_code":" @Get(':id')\n @RequirePermission('student:view')\n findOne(@Param('id', ParseIntPipe) id: number) {\n return this.service.findOne(id);\n }"}
{"path":"apps/server/src/students/students.controller.ts","start_line":173,"end_line":175,"category":"security","severity":"low","content":"In this system `idNumber` is the national ID number (身份证号, exported as its own column) while `studentNo` is the 学号 field. Logging `学号: ${dto.idNumber}` both mislabels the field and persists full national ID numbers (sensitive PII) into audit logs; the `update` handler similarly dumps `JSON.stringify(dto)`. Use `dto.studentNo` and/or mask `idNumber` in audit details.","suggestion_code":" await logAudit(this.logService, req, {\n module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.studentNo || '无'}`,\n });","existing_code":" await logAudit(this.logService, req, {\n module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,\n });"}
{"path":"apps/server/src/students/students.controller.ts","start_line":292,"end_line":295,"category":"maintainability","severity":"low","content":"The organization-name-to-ID resolution block is duplicated verbatim between `importExcel` and `matchImport`; the two paths can drift. Extract it into a private helper (e.g. `private async resolveOrganizationIds(students: ...)` with a single batch query as noted) and call it from both handlers.","suggestion_code":null,"existing_code":" if (organization) row.organizationId = organization.id;\n }\n }\n const result = await this.service.matchImport(importData);"}
{"path":"apps/server/src/rooms/room-inspections.service.ts","start_line":49,"end_line":55,"category":"bug","severity":"medium","content":"The cron-triggered path has no error handling. If `settleDate` throws (e.g., a DB outage, or `lockRoom` raises '宿舍不存在' because a room was deleted between the occupancies read and the room lock), the rejected promise from this async method is never awaited/caught by the scheduler — this can surface as an unhandled rejection (process crash on Node ≥15) and also aborts settlement for every remaining room, leaving the day's records silently incomplete. `onApplicationBootstrap` wraps the same call with `.catch()` + logging, but the cron path does not. Add a try/catch with `logger.error` here, and consider isolating per-room failures inside `settleDate` so one bad room doesn't abort the whole batch.","suggestion_code":null,"existing_code":" try {\n const today = this.getChinaDate(now);\n const targetDate = this.shiftDate(today, -1);\n await this.settleDate(targetDate);\n } finally {\n this.settling = false;\n }"}
{"path":"apps/server/src/rooms/room-inspections.service.ts","start_line":26,"end_line":31,"category":"maintainability","severity":"low","content":"`detailRepo` and `occupancyRepo` are injected but never referenced anywhere in this service — all DB access goes through `manager.getRepository(...)` / `this.dataSource.manager` (`submit`, `settleDate`, `findOccupanciesForDate`, `findAllOccupanciesForDate`). Remove the unused injections (and their `@InjectRepository` params) to eliminate dead code and unnecessary DI setup.","suggestion_code":null,"existing_code":" @InjectRepository(RoomInspectionDetail)\n private readonly detailRepo: Repository<RoomInspectionDetail>,\n @InjectRepository(Room)\n private readonly roomRepo: Repository<Room>,\n @InjectRepository(Occupancy)\n private readonly occupancyRepo: Repository<Occupancy>,"}
{"path":"apps/server/src/rooms/room-inspections.service.ts","start_line":157,"end_line":158,"category":"performance","severity":"low","content":"The per-room loop in `settleDate` processes rooms strictly sequentially and also awaits `operationLogs.log` inside the loop. Since each transaction locks a different room row, the rooms are independent and could be settled in parallel with `Promise.all` (with a shared counter guarded by `created++` in the map stage), and the operation-log writes could be batched after all settlements. This matters when an outage backlog leaves many rooms to auto-settle at 00:05.","suggestion_code":null,"existing_code":" for (const [roomId, roomOccupancies] of byRoom) {\n const result = await this.dataSource.transaction(async (manager) => {"}
{"path":"apps/server/src/rooms/room-inspections.service.ts","start_line":101,"end_line":101,"category":"maintainability","severity":"low","content":"Business status/source literals ('present'/'absent', 'manual'/'automatic') are hardcoded in several places in this file, duplicating the `RoomInspectionSource` type already exported by `room-inspection.entity.ts`. A typo in one literal would silently diverge from the entity's allowed values. Extract shared constants (or an enum) and reuse them in both `submit` and `settleDate`.","suggestion_code":null,"existing_code":" status: presentSet.has(occupancy.id) ? 'present' : 'absent',"}
{"path":"apps/server/src/students/student-import.ts","start_line":183,"end_line":184,"category":"bug","severity":"medium","content":"Timezone inconsistency in date conversion: the Date is constructed from a UTC epoch (Date.UTC(1899,11,30) + serial*86400000), but formatDate reads local-timezone getters (getFullYear/getMonth/getDate). When the Excel serial contains a time-of-day component (e.g. a date cell with a timestamp), or when the server runs west of UTC, the resulting date can be off by ±1 day. Fix by formatting with UTC getters (getUTCFullYear/getUTCMonth/getUTCDate) to stay consistent with the UTC construction, or build the Date from a local-time epoch.","suggestion_code":null,"existing_code":" const epoch = Date.UTC(1899, 11, 30);\n return formatDate(new Date(epoch + serial * 24 * 60 * 60 * 1000));"}
{"path":"apps/server/src/students/student-import.ts","start_line":197,"end_line":198,"category":"bug","severity":"low","content":"For date-typed columns, any text that does not match the YYYY-M-D pattern is silently passed through unchanged (e.g. '2024年9月1日', 'abc', or a mis-typed date). This means invalid/non-date values are imported into date fields (profileDate, startDate, examDate, recordDate) without any signal, and downstream logic expecting 'YYYY-MM-DD' may break or store garbage. Consider returning undefined for unmatched text (or explicitly normalizing more date formats) and validating at the import boundary.","suggestion_code":null,"existing_code":" const match = normalized.match(/^(\\d{4})-(\\d{1,2})-(\\d{1,2})$/u);\n if (!match) return text;"}
{"path":"apps/server/src/students/student-import.ts","start_line":263,"end_line":263,"category":"maintainability","severity":"low","content":"When none of the named sheets are found, findWorksheet silently falls back to the first worksheet (index 0 for students). If a user uploads a workbook whose first sheet is not the student sheet (e.g. '报读班型' or '考试成绩' ordered first), rows will be parsed against the wrong column definitions and produce garbage data with no error. Consider validating that the fallback sheet's headers actually match the expected columns, or returning an explicit error when the expected sheet is missing.","suggestion_code":null,"existing_code":" return fallbackIndex === undefined ? undefined : workbook.worksheets[fallbackIndex];"}
{"path":"apps/server/src/students/students.agent.service.ts","start_line":25,"end_line":33,"category":"maintainability","severity":"medium","content":"Dead code: `AGENT_STUDENT_SELECT` is declared as the documented whitelist source of truth but is never referenced anywhere (both methods hard-code their own `.select([...])` arrays instead). This is misleading and a drift hazard — e.g. the search method's inline list adds `student.createdAt` while the constant omits it. Either use the constant in both methods or remove it to avoid a false sense that the whitelist is centralized/enforced.","suggestion_code":null,"existing_code":" private static readonly AGENT_STUDENT_SELECT = [\n 'student.id',\n 'student.name',\n 'student.studentNo',\n 'student.gender',\n 'student.status',\n 'student.organizationId',\n 'organization.name',\n ] as const;"}
{"path":"apps/server/src/students/students.agent.service.ts","start_line":88,"end_line":90,"category":"bug","severity":"low","content":"User-supplied keyword is interpolated into a LIKE pattern without escaping `%`, `_`, or `\\`. A keyword containing these characters acts as a wildcard (e.g. searching `%` matches every student, `a_` also matches `ab`), silently broadening the result set beyond the intended filter. Consider escaping the input before building the pattern (and use ESCAPE '\\').","suggestion_code":" const escaped = query.keyword.replace(/[\\\\%_]/g, (c) => `\\\\${c}`);\n qb.andWhere(\"(student.name LIKE :keyword OR student.student_no LIKE :keyword ESCAPE '\\\\')\", {\n keyword: `%${escaped}%`,\n });","existing_code":" qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', {\n keyword: `%${query.keyword}%`,\n });"}
{"path":"apps/server/src/students/students.agent.service.ts","start_line":111,"end_line":116,"category":"maintainability","severity":"low","content":"The teacher-scope SQL fragment `cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)` is duplicated in `applyStudentScope` (as a join condition) and again in both the search and single-student classIds queries. If the scope logic ever changes (e.g. adding a role_type/status filter), these three copies must be updated in sync or the classIds returned to a teacher can drift out of the enforced scope. Consider extracting a shared helper/condition builder for the teacher scope.","suggestion_code":null,"existing_code":" if (scope.type === 'teacher') {\n csQb.andWhere(\n 'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',\n { scopeTeacherUserId: scope.userId },\n );\n }"}
{"path":"apps/server/src/students/students.organization.ts","start_line":0,"end_line":0,"category":"maintainability","severity":"low","content":"The business constant 'active' is hardcoded in both queries in this file (and mirrors the entity's `'active' | 'archived'` union). Per the hardcoding rule, extract it to a shared constant (e.g., `ORGANIZATION_STATUS.ACTIVE`) so the value is defined once and typos in the status string are caught at compile time.","suggestion_code":"where: { id, status: ORGANIZATION_STATUS.ACTIVE },","existing_code":"where: { id, status: 'active' },"}
{"path":"apps/server/src/students/students.organization.ts","start_line":17,"end_line":17,"category":"bug","severity":"low","content":"The entity has no unique constraint on `isHost`, so nothing prevents multiple active host organizations from existing. `findOne` without an explicit `order` then returns an arbitrary row, making the resolved \"host\" organization non-deterministic. Add an explicit `order` (e.g., `{ id: 'ASC' }`) for stable selection, or enforce single-host uniqueness at the database level.","suggestion_code":"where: { isHost: true, status: 'active' },\n order: { id: 'ASC' },","existing_code":"where: { isHost: true, status: 'active' },"}
{"path":"apps/server/src/sync/jinshuju-rules.ts","start_line":48,"end_line":48,"category":"bug","severity":"medium","content":"Non-string values (e.g., numbers/booleans returned by the Jinshuju API for number/date fields, such as a phone or student number stored as a numeric type) are silently dropped and become ''. This can cause silent data loss and missed phone/name matching in previewJinshuju/applyJinshuju without any error. Consider converting primitive values (e.g., `String(val).trim()` for non-null/undefined primitives) instead of discarding them, or at least logging a warning.","suggestion_code":"if (val === null || val === undefined) return '';\n return String(val).trim();","existing_code":"return typeof val === 'string' ? val.trim() : '';"}
{"path":"apps/server/src/sync/jinshuju-rules.ts","start_line":10,"end_line":14,"category":"maintainability","severity":"low","content":"Semantically, a missing rule (or a rule that belongs to another form) is not an HTTP 409 Conflict — this is a not-found/forbidden situation, and callers (previewJinshuju/applyJinshuju) pass a user-supplied ruleId. Additionally, the lookup is not scoped by formToken, so the rule is loaded first and then discarded. It would be simpler and more correct to query with `where: { id, formToken }` and throw `NotFoundException('规则不存在')` when not found, and use a more appropriate exception (e.g., ForbiddenException) for a token mismatch if the distinction is needed.","suggestion_code":null,"existing_code":"const rule = await repo.findOne({ where: { id } });\n if (!rule) throw new ConflictException('规则不存在');\n if (rule.formToken !== formToken) {\n throw new ConflictException('匹配规则不属于当前表单');\n }"}
{"path":"apps/server/src/sync/jinshuju-rules.ts","start_line":19,"end_line":19,"category":"bug","severity":"low","content":"`formToken.trim()` will throw a TypeError if `formToken` is `undefined`/`null` at runtime (TypeScript types don't protect against that at runtime, e.g. when called from request DTOs). Guard against null/undefined before calling trim.","suggestion_code":"if (!formToken?.trim()) throw new ConflictException('表单 Token 不能为空');","existing_code":"if (!formToken.trim()) throw new ConflictException('表单 Token 不能为空');"}
{"path":"apps/server/src/students/students.lifecycle.service.ts","start_line":194,"end_line":199,"category":"bug","severity":"medium","content":"The bare `catch {}` swallows ALL errors thrown by `assertNoStudentReferences`, not just the `BadRequestException` raised when related data exists. Any unexpected failure (e.g., a transient DB/connection error during the 15 COUNT queries) is silently misreported as \"存在关联数据\" and the student is skipped with a misleading message. Catch only `BadRequestException` and rethrow other errors so real failures surface.","suggestion_code":" try {\n await this.assertNoStudentReferences(student.id);\n } catch (err) {\n if (err instanceof BadRequestException) {\n skipped.push(`${student.name}(存在关联数据)`);\n continue;\n }\n throw err;\n }","existing_code":" try {\n await this.assertNoStudentReferences(student.id);\n } catch {\n skipped.push(`${student.name}(存在关联数据)`);\n continue;\n }"}
{"path":"apps/server/src/students/students.lifecycle.service.ts","start_line":179,"end_line":185,"category":"maintainability","severity":"low","content":"The ID normalization/validation logic (`uniqueIds`, `Number.isInteger` check, existence check) is duplicated verbatim between `batchPurge` and `batchRestore`. Extract a private helper (e.g., `resolveStudents(ids): Promise<Student[]>`) that dedupes, validates positive integers, throws `BadRequestException` for invalid input and `NotFoundException` for missing students, then reuse it in both methods to keep the logic consistent.","suggestion_code":null,"existing_code":" const uniqueIds = [...new Set(ids || [])];\n if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的学生');\n if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {\n throw new BadRequestException('学生 ID 无效');\n }\n const students = await this.repo.find({ where: { id: In(uniqueIds) } });\n if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在');"}
{"path":"apps/server/src/students/students.lifecycle.service.ts","start_line":89,"end_line":89,"category":"maintainability","severity":"low","content":"The status values 'archived'/'active' are hardcoded in `batchRemove`, `restore`, `purge`, `batchPurge`, and `batchRestore`. These are business-critical state values; define shared constants (or a status enum) so typos/mismatched strings in future changes are caught at compile time.","suggestion_code":null,"existing_code":" .set({ status: 'archived' })"}
{"path":"apps/server/src/students/students.lifecycle.service.ts","start_line":58,"end_line":59,"category":"bug","severity":"low","content":"`studentIds.length` will throw a TypeError if the method is ever invoked with `undefined`/`null` (e.g., a malformed request payload). Guard with `studentIds?.length` or default `studentIds || []` before accessing `.length`.","suggestion_code":" async getArchiveExportMaps(studentIds: number[] = []) {\n if (studentIds.length === 0) {","existing_code":" async getArchiveExportMaps(studentIds: number[]) {\n if (studentIds.length === 0) {"}
{"path":"apps/server/src/students/students.lifecycle.service.ts","start_line":200,"end_line":201,"category":"performance","severity":"low","content":"Deletion of each student is awaited sequentially in a loop, but the per-student delete operations are independent of each other. For large batches this serializes N round-trips to the DB; use `Promise.all` with per-student `catch` to keep the skip semantics while running deletes concurrently.","suggestion_code":null,"existing_code":" await this.repo.delete(student.id);\n deleted.push(student.id);"}
{"path":"apps/server/src/students/students.lifecycle.service.ts","start_line":80,"end_line":83,"category":"bug","severity":"low","content":"Race condition / non-atomic status check: `batchRemove` reads `s.status` here and then bulk-updates with `WHERE id IN (...)` without re-asserting `status != 'archived'`. Two concurrent `batchRemove` calls (or one `batchRemove` racing with `restore`) can both count the same student as affected, producing inflated/incorrect result counts. Add the status predicate to the UPDATE (e.g., `.where('id IN (:...ids) AND status != :archived', { ids: targetIds, archived: 'archived' })`) so `result.affected` reflects only students actually transitioned.","suggestion_code":null,"existing_code":" for (const s of students) {\n if (s.status === 'archived') skipped.push(s.name);\n else targetIds.push(s.id);\n }"}
{"path":"apps/server/src/sync/dto/schedule-sync.dto.ts","start_line":6,"end_line":8,"category":"bug","severity":"high","content":"Contradictory validation rules make `dateFrom` impossible to validate successfully. `@Matches(/^\\d{4}-\\d{2}-\\d{2}$/)` only accepts a bare `YYYY-MM-DD` date, but `@IsISO8601({ strict: true })` (validator.js strict mode) requires a full ISO 8601 timestamp with the `T` separator, time portion, and optional timezone (e.g. `2026-08-09T00:00:00.000Z`). Since class-validator requires all constraints on a property to pass, no value can ever satisfy both — any request that includes `dateFrom` will be rejected with a 400, making the query parameter unusable. Fix by picking one format: drop `strict: true` so `@IsISO8601()` accepts plain dates, or align the regex to the strict ISO format.","suggestion_code":" @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601()\n dateFrom?: string;","existing_code":" @Matches(/^\\d{4}-\\d{2}-\\d{2}$/)\n @IsISO8601({ strict: true })\n dateFrom?: string;"}
{"path":"apps/server/src/students/students.module.ts","start_line":28,"end_line":48,"category":"maintainability","severity":"medium","content":"This module registers 19 entities spanning many unrelated domains (attendance, finance: Bill/Deposit/PersonalExpense, room inspection, exams, archives, occupancy, wallet) in a single TypeOrmModule.forFeature block. This turns StudentsModule into a \"god module\" with very broad coupling: every consumer that imports StudentsModule (e.g. agent-tools) transitively pulls in repository metadata for all these domains, and StudentsService grows to aggregate lifecycle/import/agent concerns. Consider splitting into cohesive sub-modules (e.g. student core, student lifecycle/archive, student finance) and importing them here, so entity registration and providers stay scoped to their actual domain.","suggestion_code":null,"existing_code":" TypeOrmModule.forFeature([\n Student,\n Class,\n ClassStudent,\n AttendanceRecord,\n Organization,\n ClassTeacher,\n StudentProfile,\n StudentEnrollment,\n ExamScore,\n LearningRecord,\n ResultArchive,\n Occupancy,\n PersonalExpense,\n Bill,\n Deposit,\n ArchiveAttachment,\n StudentDingMapping,\n StudentWallet,\n RoomInspectionDetail,\n ]),"}
{"path":"apps/server/src/sync/sync.controller.ts","start_line":203,"end_line":203,"category":"performance","severity":"medium","content":"`limit` is passed straight to TypeORM `take` with no upper bound (and no lower-bound check either — 0 or negative values are accepted by ParseIntPipe). A client can request an arbitrarily large limit (e.g. `limit=9999999`), causing an oversized DB query and memory pressure. Clamp the value, e.g. `Math.min(Math.max(limit ?? 50, 1), 200)`, and validate before passing to the service.","suggestion_code":" const safeLimit = Math.min(Math.max(limit ?? 50, 1), 200);\n return this.syncService.getLogs(platform, safeLimit);","existing_code":" return this.syncService.getLogs(platform, limit ?? 50);"}
{"path":"apps/server/src/sync/sync.controller.ts","start_line":23,"end_line":28,"category":"bug","severity":"medium","content":"`platform` is typed as `SyncPlatform` but is never validated at runtime. In `syncService.triggerSync`, any value that is not one of the three known platforms (e.g. a typo like `dingtalk_student`) silently falls through to the final branch and triggers a FULL sync of all three platforms (students + attendance + wecom) — an expensive and surprising side effect for a mistyped query param. Validate `platform` against the allowed enum values and throw `BadRequestException` for unknown values.","suggestion_code":" if (platform && !['dingtalk_students', 'dingtalk_attendance', 'wecom'].includes(platform)) {\n throw new BadRequestException(`unknown platform: ${platform}`);\n }\n const logs = await this.syncService.triggerSync(\n platform,\n rootId,\n createMissing !== 'false',\n updateProfile !== 'false',\n );","existing_code":" const logs = await this.syncService.triggerSync(\n platform,\n rootId,\n createMissing !== 'false',\n updateProfile !== 'false',\n );"}
{"path":"apps/server/src/sync/sync.controller.ts","start_line":142,"end_line":144,"category":"bug","severity":"low","content":"Only the array non-emptiness of `decisions` is checked; each item is not validated. `action` is only typed as `'match' | 'create' | 'skip'` at compile time, but a malformed request (e.g. `action: 'delete'`, or `action: 'match'` without `matchStudentId`) passes straight through to `syncService.applyJinshuju`, where such entries are silently skipped with no error surfaced to the user, and `createName`/`createPhone` are unconstrained strings. Validate each decision item (action whitelist, required `serialNumber`, conditional `matchStudentId`/`createName`) and return a 400 with a clear message.","suggestion_code":" if (!Array.isArray(body.decisions) || body.decisions.length === 0) {\n throw new BadRequestException('decisions 不能为空');\n }\n for (const d of body.decisions) {\n if (typeof d.serialNumber !== 'number' || !['match', 'create', 'skip'].includes(d.action)) {\n throw new BadRequestException('decisions 包含无效项');\n }\n if (d.action === 'match' && !d.matchStudentId) {\n throw new BadRequestException('match 操作需要 matchStudentId');\n }\n }","existing_code":" if (!Array.isArray(body.decisions) || body.decisions.length === 0) {\n throw new BadRequestException('decisions 不能为空');\n }"}
{"path":"apps/server/src/sync/sync.controller.ts","start_line":22,"end_line":22,"category":"maintainability","severity":"low","content":"The default root department id `1` is a business-specific magic number hardcoded here (and repeated in the two org-tree endpoints below). If the DingTalk root department changes, it must be updated in several places. Consider extracting it to a named constant/config value so the default is defined once.","suggestion_code":null,"existing_code":" const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;"}
{"path":"apps/server/src/students/students.service.ts","start_line":110,"end_line":116,"category":"security","severity":"high","content":"Authorization gap: unlike `findAll` and `getFilterLookups`, this method accepts no `accessibleClassIds` and the controller (`GET /students/basic-lookups`, permission `student:basic-view`/`student:view`) calls it without class scoping. As a result, any teacher who can view students gets every active student's PII (phone, studentNo, gender) across all classes — a horizontal privilege escalation inconsistent with the class-scoped behavior elsewhere in this file. Suggest accepting `accessibleClassIds` and filtering via `classStudentRepo` (same as `findAll`), or restricting this endpoint to manage-all users.","suggestion_code":" async getBasicLookups(accessibleClassIds?: number[]) {\n const where: FindOptionsWhere<Student> = { status: 'active' };\n if (accessibleClassIds) {\n const classStudents = await this.classStudentRepo.find({\n where: { classId: In(accessibleClassIds), status: 'active' },\n });\n where.id = In([...new Set(classStudents.map((item) => item.studentId))]);\n }\n return this.repo.find({\n select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'],\n where,\n order: { name: 'ASC' },\n });\n }","existing_code":" async getBasicLookups() {\n return this.repo.find({\n select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'],\n where: { status: 'active' },\n order: { name: 'ASC' },\n });\n }"}
{"path":"apps/server/src/students/students.service.ts","start_line":134,"end_line":134,"category":"bug","severity":"medium","content":"User-supplied `%` and `_` characters are not escaped in the `Like` pattern, so searching for e.g. `100%` or `a_b` matches many more rows than intended (wildcard injection). The leading wildcard also defeats index usage on the name column. Escape `\\`, `%`, `_` before building the pattern and pass the escape char to TypeORM.","suggestion_code":" if (query?.name) {\n const escaped = query.name.replace(/[\\\\%_]/g, '\\\\$&');\n where.name = Like(`%${escaped}%`, '\\\\');\n }","existing_code":" if (query?.name) where.name = Like(`%${query.name}%`);"}
{"path":"apps/server/src/students/students.service.ts","start_line":293,"end_line":296,"category":"maintainability","severity":"low","content":"Business status values are hardcoded as magic strings here ('正常', '缺勤', '迟到', '请假' and their English aliases). These are duplicated inline and easy to get out of sync with the AttendanceRecord entity/enum values. Extract the canonical status set into a shared constant/enum and map legacy Chinese values through it.","suggestion_code":null,"existing_code":" const present = records.filter((r) => r.status === 'present' || r.status === '正常').length;\n const absent = records.filter((r) => r.status === 'absent' || r.status === '缺勤').length;\n const late = records.filter((r) => r.status === 'late' || r.status === '迟到').length;\n const leave = records.filter((r) => r.status === 'leave' || r.status === '请假').length;"}
{"path":"apps/server/src/students/students.service.ts","start_line":58,"end_line":60,"category":"maintainability","severity":"low","content":"The child services (StudentsImportService, StudentsLifecycleService, StudentsAgentService) are manually instantiated with positionally wired repository arguments via lazy getters, bypassing Nest's DI container. This is brittle (any constructor reorder/extension silently fails or requires touching this file) and these instances miss DI-provided features. Prefer declaring them as providers and injecting (use forwardRef if needed to break circular deps), or at minimum document why manual composition is required here.","suggestion_code":null,"existing_code":" private get imports(): StudentsImportService {\n if (!this.importService) {\n this.importService = new StudentsImportService("}
{"path":"apps/server/src/sync/schedule-sync.service.ts","start_line":206,"end_line":207,"category":"bug","severity":"high","content":"Attendance group name is derived only from className. If two different classes share the same name (very common in schools, e.g. \"高一(1)班\" in different grades/years), the second class will hit the group cached under the same name and `updateAttendanceGroup` will overwrite the group's members and shift_ids with the second class's data. The first class's already-written schedule items then point to a group whose members were replaced, corrupting attendance data. Unlike shift names, the group name has no period component to disambiguate. Suggest appending the classId: `排课_${className}_${classId}`.","suggestion_code":" // 创建/匹配该班级的考勤组\n const groupName = `排课_${className}_${classId}`;","existing_code":" // 创建/匹配该班级的考勤组\n const groupName = `排课_${className}`;"}
{"path":"apps/server/src/sync/schedule-sync.service.ts","start_line":67,"end_line":69,"category":"style","severity":"low","content":"This `!=` comparison violates the project's strict-equality rule (== and != are prohibited). Use `!== null && !== undefined`, or explicitly handle null with a typed guard.","suggestion_code":" where: { status: 'active' },\n });\n const schedules = allSchedules.filter((s) => s.classId !== null && s.classId !== undefined);","existing_code":" where: { status: 'active' },\n });\n const schedules = allSchedules.filter((s) => s.classId != null);"}
{"path":"apps/server/src/sync/schedule-sync.service.ts","start_line":294,"end_line":295,"category":"style","severity":"low","content":"Same `!=` issue in `getStatus` (identical filter copied from `syncAll`). The active-schedule query + classId extraction is duplicated in both methods; consider extracting a small private helper to avoid drift.","suggestion_code":" const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } });\n const schedules = allSchedules.filter((s) => s.classId !== null && s.classId !== undefined);","existing_code":" const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } });\n const schedules = allSchedules.filter((s) => s.classId != null);"}
{"path":"apps/server/src/sync/schedule-sync.service.ts","start_line":285,"end_line":289,"category":"maintainability","severity":"low","content":"The docstring here describes `buildClassDingUserMap` (copy-pasted), not `getStatus` itself, and the `_targetDate` parameter is unused. Either remove the parameter, use it, or fix the comment to describe the actual behavior of this method.","suggestion_code":null,"existing_code":" /**\n * 构建 classId → 学生钉钉 userId 列表。\n * 一次性查询所有班级的活跃学生与钉钉映射,避免 N+1。\n */\n async getStatus(_targetDate: string): Promise<{"}
{"path":"apps/server/src/sync/schedule-sync.service.ts","start_line":46,"end_line":46,"category":"maintainability","severity":"low","content":"`opUserId = 'manager'` is a hardcoded business value as the default DingTalk operator. If the actual operator's userId is not exactly \"manager\", every create/update call (shifts, groups, scheduleUsers) will fail. Consider injecting this via configuration/DI instead of a magic default.","suggestion_code":null,"existing_code":" opUserId = 'manager',"}
{"path":"apps/server/src/sync/schedule-sync.service.ts","start_line":106,"end_line":106,"category":"performance","severity":"low","content":"The per-unique-shift `upsertShift` calls are executed strictly sequentially (one network round-trip each). For many classes × time-slot combinations this adds up. Since each shift name is unique, these are independent operations and could be run with bounded-concurrency `Promise.all` to reduce total sync time (while still respecting DingTalk rate limits).","suggestion_code":null,"existing_code":" for (const [key, { className, periods }] of uniqueShifts) {"}
{"path":"apps/server/src/sync/sync-runner.ts","start_line":43,"end_line":45,"category":"bug","severity":"high","content":"Errors thrown by `releaseLease` in the `finally` block will mask the original result: if the operation succeeded (and the log/state were updated) but releasing the lease fails (e.g. a transient DB error), the caller receives an exception for a fully successful sync, and the lease stays held until it goes stale. Wrap the release in a try/catch and only log the failure instead of rethrowing.","suggestion_code":" } finally {\n try {\n await this.releaseLease(platform, runId);\n } catch (releaseError) {\n this.logger.error(`Failed to release lease for ${platform}: ${releaseError}`);\n }\n }","existing_code":" } finally {\n await this.releaseLease(platform, runId);\n }"}
{"path":"apps/server/src/sync/sync-runner.ts","start_line":40,"end_line":42,"category":"bug","severity":"medium","content":"If `finishSyncLog` itself throws here (e.g. DB unavailable), the original sync error is replaced by the `finishSyncLog` error, losing the root cause and leaving the log stuck in 'running'. Wrap the failure-log write in try/catch and always rethrow the original `error`.","suggestion_code":" if (log) {\n try {\n await this.finishSyncLog(log, 'failed', 0, message);\n } catch (finishError) {\n this.logger.error(`Failed to mark sync log failed for ${platform}: ${finishError}`);\n }\n }\n this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined);\n throw error;","existing_code":" if (log) await this.finishSyncLog(log, 'failed', 0, message);\n this.logger.error(`${platform} sync failed: ${message}`, error instanceof Error ? error.stack : undefined);\n throw error;"}
{"path":"apps/server/src/sync/sync-runner.ts","start_line":35,"end_line":35,"category":"bug","severity":"medium","content":"The `lastSyncAt` update is not scoped to the acquired lease's `runId`. In a stale-lease takeover (process A runs longer than LEASE_MS, process B takes over), A can finish later and overwrite B's `lastSyncAt` with its own completion time, corrupting the incremental-sync watermark. Scope the update with `AND run_id = :runId` (same as `releaseLease` does) so only the lease holder can advance the watermark.","suggestion_code":" await this.syncStateRepo\n .createQueryBuilder()\n .update()\n .set({ lastSyncAt: new Date() })\n .where('platform = :platform AND run_id = :runId', { platform, runId })\n .execute();","existing_code":" await this.syncStateRepo.update({ platform }, { lastSyncAt: new Date() });"}
{"path":"apps/server/src/sync/sync-runner.ts","start_line":8,"end_line":8,"category":"bug","severity":"medium","content":"The lease has a fixed 30-minute timeout with no renewal/heartbeat. If the sync operation legitimately runs longer than `LEASE_MS` (common for large full syncs), another instance can acquire the lease and start a concurrent run of the same platform, causing duplicate imports and conflicting watermark updates. Make the lease duration configurable (e.g. via ConfigService) and/or refresh `runningSince` during long-running operations so a live run is not treated as stale.","suggestion_code":null,"existing_code":"const LEASE_MS = 30 * 60 * 1000;"}
{"path":"apps/server/src/sync/sync-runner.ts","start_line":60,"end_line":64,"category":"maintainability","severity":"low","content":"Lease staleness is judged by comparing the DB column `running_since` against app-server time (`new Date()`). If the application server clock drifts from the database clock, leases can be considered stale too early (concurrent runs) or too late (dead runs block for longer than expected). Consider comparing against the DB clock (`NOW()`/`CURRENT_TIMESTAMP`) or normalizing both sides to a single time source.","suggestion_code":null,"existing_code":" .set({ runId, runningSince: new Date() })\n .where('platform = :platform', { platform })\n .andWhere('(running_since IS NULL OR running_since < :staleBefore)', {\n staleBefore: new Date(Date.now() - LEASE_MS),\n })"}
{"path":"apps/server/src/wallets/wallets.controller.ts","start_line":39,"end_line":41,"category":"bug","severity":"medium","content":"`studentId` is an optional query parameter typed as `string`, so when it is missing/invalid `Number(undefined)` yields `NaN`. The service then runs `transactionRepo.find({ where: { studentId: NaN } })`, silently returning an empty list instead of a meaningful 400 error. Validate the parameter explicitly, e.g. `@Query('studentId', ParseIntPipe) studentId: number` and pass it directly, so a missing/non-numeric value is rejected by Nest's validation pipeline.","suggestion_code":" findTransactions(@Query('studentId', ParseIntPipe) studentId: number) {\n return this.service.findTransactions(studentId);\n }","existing_code":" findTransactions(@Query('studentId') studentId: string) {\n return this.service.findTransactions(Number(studentId));\n }"}
{"path":"apps/server/src/wallets/wallets.controller.ts","start_line":46,"end_line":48,"category":"bug","severity":"medium","content":"The wallet balance change (a DB transaction) is committed before `logService.log` is awaited. If the audit-log write fails, the exception propagates to the client as an error even though the balance change already succeeded — a retry from the client (without a matching `operationId`) would double-charge the student, and the audit trail for a successful money operation would be lost. Consider wrapping the log call in try/catch (logging failure independently, e.g. via the logger) so the response reflects the actual committed state, or record the log inside the same transaction as the balance change. Note that failed operations are also not logged at all here, which may matter for audit completeness.","suggestion_code":null,"existing_code":" const result = await this.service.changeBalance(dto, req.user?.id);\n const { ipAddress, userAgent } = extractRequestInfo(req);\n await this.logService.log({"}
{"path":"apps/server/src/wallets/wallets.controller.ts","start_line":65,"end_line":68,"category":"maintainability","severity":"low","content":"The logging logic (`extractRequestInfo` + `logService.log` with the same module/targetType and nearly identical detail format) is duplicated verbatim between `changeBalance` and `batchChangeBalance`. Extract it into a small private helper (e.g. `logBalanceChange(req, action, targetId, detail)`) to avoid divergence when the log schema changes.","suggestion_code":null,"existing_code":" async batchChangeBalance(@Body() dto: BatchChangeWalletBalanceDto, @Request() req: AuthenticatedRequest) {\n const result = await this.service.batchChangeBalance(dto, req.user?.id);\n const { ipAddress, userAgent } = extractRequestInfo(req);\n await this.logService.log({"}
{"path":"apps/server/src/wallets/dto/wallet.dto.ts","start_line":27,"end_line":31,"category":"maintainability","severity":"low","content":"This DTO duplicates operationId/amount/type/description from ChangeWalletBalanceDto almost verbatim. Extract a shared base class (e.g., BaseWalletBalanceChangeDto) with these common fields and have both DTOs extend it, keeping only studentId / studentIds in each subclass.","suggestion_code":null,"existing_code":"export class BatchChangeWalletBalanceDto {\n @IsOptional()\n @IsString()\n @Matches(/^[\\w-]{8,64}$/)\n operationId?: string;"}
{"path":"apps/server/src/wallets/dto/wallet.dto.ts","start_line":35,"end_line":41,"category":"bug","severity":"medium","content":"studentIds is explicitly transformed with @Type(() => Number), but amount is not. If a client sends amount as a JSON string (e.g. \"10.5\", common when amounts come from forms or spreadsheets) and global implicit conversion is not enabled, IsNumber will reject it while studentIds would still be accepted. Apply @Type(() => Number) to amount in both DTOs for consistent, predictable transformation.","suggestion_code":" @Type(() => Number)\n @IsNumber({ maxDecimalPlaces: 2 })\n @NotEquals(0)\n amount: number;","existing_code":" @IsInt({ each: true })\n @Type(() => Number)\n studentIds: number[];\n\n @IsNumber({ maxDecimalPlaces: 2 })\n @NotEquals(0)\n amount: number;"}
{"path":"apps/server/src/wallets/dto/wallet.dto.ts","start_line":33,"end_line":37,"category":"security","severity":"medium","content":"studentIds has no upper size bound. A malicious client can submit an arbitrarily large array, causing excessive validation CPU/memory usage and a huge batch operation (resource-exhaustion / DoS risk). Add @ArrayMaxSize (e.g. 1000) and import it from class-validator.","suggestion_code":" @IsArray()\n @ArrayNotEmpty()\n @ArrayMaxSize(1000)\n @IsInt({ each: true })\n @Type(() => Number)\n studentIds: number[];","existing_code":" @IsArray()\n @ArrayNotEmpty()\n @IsInt({ each: true })\n @Type(() => Number)\n studentIds: number[];"}
{"path":"apps/server/src/wallets/dto/wallet.dto.ts","start_line":13,"end_line":15,"category":"bug","severity":"medium","content":"Storing wallet amounts as a JS number (IEEE-754 float64) is risky for financial operations: values like 0.1 + 0.2 produce 0.30000000000000004, and @IsNumber({ maxDecimalPlaces: 2 }) also fails to catch such derived values (and misses exponential forms like 1e-7). Consider representing amounts in integer minor units (cents) or using a decimal library, keeping this decorator only as a validation layer.","suggestion_code":null,"existing_code":" @IsNumber({ maxDecimalPlaces: 2 })\n @NotEquals(0)\n amount: number;"}
{"path":"apps/server/src/wallets/wallets.module.ts","start_line":14,"end_line":14,"category":"maintainability","severity":"low","content":"The `Occupancy` entity is registered in `TypeOrmModule.forFeature` but no provider in this module ever injects an `Occupancy` repository — `WalletsService` only references it indirectly via relation joins (`student.occupancies` / `room.occupancies`), which work off the parent entity metadata, not module-scoped repository registration. This is dead registration (and an unused import). Remove `Occupancy` from the `forFeature` list unless an `Occupancy` repository is actually injected here.","suggestion_code":"imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill, Room]), OperationLogsModule],","existing_code":"imports: [TypeOrmModule.forFeature([StudentWallet, WalletTransaction, Student, Bill, Room, Occupancy]), OperationLogsModule],"}
{"path":"apps/server/src/sync/sync.service.ts","start_line":223,"end_line":225,"category":"bug","severity":"high","content":"When a Jinshuju entry has all mapped fields empty, `mappedValues` becomes `{}` and `manager.update(Student, decision.matchStudentId, {})` throws TypeORM's `UpdateValuesMissingError` (\"Cannot perform update query because update values are not defined\"), which rolls back the entire jinshuju sync transaction and fails the whole sync. Guard the update with a non-empty check (or skip the entry) before calling `update`.","suggestion_code":" if (decision.action === 'match' && decision.matchStudentId) {\n if (Object.keys(mappedValues).length > 0) {\n await manager.update(Student, decision.matchStudentId, mappedValues);\n matched++;\n }","existing_code":" if (decision.action === 'match' && decision.matchStudentId) {\n await manager.update(Student, decision.matchStudentId, mappedValues);\n matched++;"}
{"path":"apps/server/src/sync/sync.service.ts","start_line":20,"end_line":20,"category":"maintainability","severity":"low","content":"`LEASE_MS` is declared but never used in this file — `SyncRunner` already defines its own module-level `LEASE_MS` for lease staleness. This is dead code and should be removed to avoid confusion about which constant actually controls the lease.","suggestion_code":null,"existing_code":" private static readonly LEASE_MS = 30 * 60 * 1000;"}
{"path":"apps/server/src/sync/sync.service.ts","start_line":50,"end_line":56,"category":"maintainability","severity":"low","content":"Nested ternary expressions are not allowed per the review rules. This message-building chain is hard to read; extract it into an if/else helper (or a small local function) to keep the logic flat and readable.","suggestion_code":" message: (() => {\n if (result.conflicts.length) return JSON.stringify(result.conflicts.slice(0, 20));\n if (!createMissing && !updateProfile) return `手机号绑定 ${result.matched ?? 0} 人,跳过 ${result.skipped ?? 0} 人`;\n if (createMissing) return `新增 ${result.created} 人,更新 ${result.updated} 人,手机号绑定 ${result.matched ?? 0} 人`;\n return `手机号绑定 ${result.matched ?? 0} 人,更新 ${result.updated} 人,跳过 ${result.skipped ?? 0} 人`;\n })()","existing_code":" message: result.conflicts.length\n ? JSON.stringify(result.conflicts.slice(0, 20))\n : !createMissing && !updateProfile\n ? `手机号绑定 ${result.matched ?? 0} 人,跳过 ${result.skipped ?? 0} 人`\n : createMissing\n ? `新增 ${result.created} 人,更新 ${result.updated} 人,手机号绑定 ${result.matched ?? 0} 人`\n : `手机号绑定 ${result.matched ?? 0} 人,更新 ${result.updated} 人,跳过 ${result.skipped ?? 0} 人`,"}
{"path":"apps/server/src/sync/sync.service.ts","start_line":261,"end_line":265,"category":"performance","severity":"low","content":"These three syncs are independent (different platforms and separate leases), but they run sequentially and `SyncRunner.run` rethrows on failure — so if `syncDingTalkStudents` fails, attendance and WeCom syncs are silently skipped for this trigger. Consider isolating failures (e.g., `Promise.all`/`allSettled` or try/catch per sync) so one platform's failure doesn't block the others.","suggestion_code":null,"existing_code":" return [\n await this.syncDingTalkStudents(rootDeptId, createMissing, updateProfile),\n await this.syncDingTalkAttendance(),\n await this.syncWeCom(),\n ];"}
{"path":"apps/server/src/sync/schedule-sync.helpers.ts","start_line":105,"end_line":105,"category":"style","severity":"low","content":"Loose equality `==` is used here, which violates the project's strict-equality rule. Since `classId` is typed `number | null`, `=== null` is sufficient and equivalent here.","suggestion_code":" if (schedule.classId === null || schedule.weekDay !== weekDay) continue;","existing_code":" if (schedule.classId == null || schedule.weekDay !== weekDay) continue;"}
{"path":"apps/server/src/sync/schedule-sync.helpers.ts","start_line":97,"end_line":100,"category":"bug","severity":"low","content":"`new Date('...T00:00:00.000Z')` on a malformed `syncFrom`/`syncTo` (e.g., user-supplied `dateFrom`) produces an Invalid Date, and the loop condition `date <= toDate` silently evaluates to false — the function returns an empty plan list with no error or log, masking configuration mistakes. Consider validating the input date format (e.g., `/^\\d{4}-\\d{2}-\\d{2}$/` and `Number.isNaN(fromDate.getTime())`) and throwing/logging a clear error instead of failing silently.","suggestion_code":null,"existing_code":" const fromDate = new Date(`${syncFrom}T00:00:00.000Z`);\n const toDate = new Date(`${syncTo}T00:00:00.000Z`);\n\n for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {"}
{"path":"apps/server/src/sync/schedule-sync.helpers.ts","start_line":170,"end_line":173,"category":"bug","severity":"low","content":"No validation of the `HH:mm` input: a malformed or empty time yields `NaN` for hour/minute, which silently propagates into downstream shift parameters (`across` flag, `absenteeism_late_minutes` via `minutesBetween`, and schedule `work_date`), corrupting DingTalk sync data. Validate the format (e.g., `/^\\d{1,2}:\\d{2}$/`) or handle NaN explicitly.","suggestion_code":null,"existing_code":"export function toMinutes(time: string): number {\n const [hour, minute] = time.split(':').map(Number);\n return hour * 60 + minute;\n}"}
{"path":"apps/server/src/sync/schedule-sync.helpers.ts","start_line":100,"end_line":104,"category":"performance","severity":"low","content":"This is an O(days × schedules) nested loop: for every date in the range it re-scans all schedules and re-formats the date string. For long ranges (e.g., a year, 365 iterations) with many active schedules this adds avoidable CPU/GC pressure. Consider iterating schedules once and generating their applicable dates (or pre-indexing schedules by `weekDay`/date bounds) instead.","suggestion_code":null,"existing_code":" for (let date = new Date(fromDate); date <= toDate; date.setUTCDate(date.getUTCDate() + 1)) {\n const dateStr = dayjs(date).utcOffset(8).format('YYYY-MM-DD');\n const weekDay = date.getUTCDay() === 0 ? 7 : date.getUTCDay();\n\n for (const schedule of schedules) {"}
{"path":"apps/server/src/sync/schedule-sync.helpers.ts","start_line":54,"end_line":56,"category":"performance","severity":"low","content":"`In(classIds)` / `In(studentIds)` builds an unbounded SQL `IN` list. When a sync covers many classes/students (thousands of ids), this can exceed the DB's parameter limit (e.g., SQLite's variable limit or MySQL's max packet) and degrade query performance. Consider chunking the id lists (e.g., batches of 5001000) or querying by a JOIN.","suggestion_code":null,"existing_code":" const links = await classStudentRepo.find({\n where: { classId: In(classIds), status: 'active' },\n });"}
{"path":"apps/server/src/sync/schedule-sync.helpers.ts","start_line":153,"end_line":157,"category":"maintainability","severity":"low","content":"This function applies the single `dingUserIds` list to every plan in `plans`. It is only correct because the current caller passes per-class plan subsets; if `plans` ever contains multiple classes, students from one class would be assigned schedule items for other classes' dates. Make the grouping explicit (e.g., take a `classId` parameter or a per-plan user list) or document the precondition in a comment.","suggestion_code":null,"existing_code":"export function expandDailySchedulePlans(\n plans: DailySchedulePlan[],\n dingUserIds: string[],\n planToShiftId: Map<string, number>,\n): DingTalkScheduleItem[] {"}
{"path":"apps/server/src/wallets/wallets.service.ts","start_line":200,"end_line":201,"category":"bug","severity":"high","content":"Concurrency / double-spend risk: `debitBill` (and `refundBill`/`settleOutstandingBills`) read `wallet.balance` with `getOrCreateWallet(manager, bill.studentId)` (lock defaults to false → plain `findOne`, no `FOR UPDATE`). External callers (`bills.service.ts`, `bills-generation.service.ts`) invoke `debitBill` inside their own transactions without ever locking the wallet row. In MySQL/InnoDB a plain SELECT is a non-blocking consistent read, so two concurrent debits for the same student (e.g. two bills generated in parallel, or a debit racing a wallet adjustment) can both read the same balance and both apply it → lost update / balance below zero / bill marked paid on money already spent. Since all callers pass a transactional `EntityManager`, read the wallet with `setLock('pessimistic_write')` (pass lock=true) in `debitBill`/`refundBill`, or use an atomic conditional update (`UPDATE student_wallets SET balance = balance - x WHERE student_id = ? AND balance >= x`).","suggestion_code":" const wallet = await this.getOrCreateWallet(manager, bill.studentId, true);\n const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining));","existing_code":" const wallet = await this.getOrCreateWallet(manager, bill.studentId);\n const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining));"}
{"path":"apps/server/src/wallets/wallets.service.ts","start_line":175,"end_line":175,"category":"bug","severity":"medium","content":"`operationId` from the DTO is validated as `^[\\w-]{8,64}$` (up to 64 chars), but the batch path stores `${operationId}:${studentId}` into `wallet_transactions.operation_id`, which is `varchar(64)`. For a long base operationId the suffixed value overflows the column: in strict SQL mode the insert fails and rolls back the whole batch transaction; in non-strict mode it is silently truncated, corrupting the stored id and breaking audit/idempotency lookups. Consider storing the base operationId without the student suffix (the batch transaction row already identifies the operation), or widen/limit the column.","suggestion_code":null,"existing_code":" operationId ? `${operationId}:${studentId}` : undefined,"}
{"path":"apps/server/src/wallets/wallets.service.ts","start_line":295,"end_line":297,"category":"maintainability","severity":"medium","content":"The bare `catch {}` swallows every database error (connection loss, unrelated constraint violations, etc.), not just the duplicate-row race the comment assumes; execution then continues and, if the wallet still can't be found, surfaces a misleading '学生钱包创建失败' instead of the real failure. Additionally `manager.insert` always exists on `EntityManager`, so the `else` branch is dead code. Prefer an idempotent insert (`INSERT ... ON DUPLICATE KEY UPDATE` / `orIgnore()`) followed by a re-query, or narrow the catch to duplicate-key errors and rethrow anything else.","suggestion_code":null,"existing_code":" } catch {\n // A concurrent request may have inserted the one wallet row.\n }"}
{"path":"apps/server/src/wallets/wallets.service.ts","start_line":102,"end_line":102,"category":"performance","severity":"low","content":"`findTransactions` loads the student's entire transaction history with no pagination or limit. `wallet_transactions` grows indefinitely (every recharge/adjustment/bill payment/refund adds a row), so this endpoint's latency and memory usage will degrade over time. Add take/skip or cursor pagination, or at least a `take` cap.","suggestion_code":null,"existing_code":" return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });"}
{"path":"ecosystem.config.cjs","start_line":25,"end_line":26,"category":"bug","severity":"medium","content":"Log paths are relative, and PM2 resolves relative `out_file`/`error_file` paths against the working directory where the `pm2` command is launched — not against the app's `cwd` (DEPLOY_DIR). Since this config explicitly supports being started from any directory via DEPLOY_DIR, logs may be written to unexpected locations (or the write fails if the directory doesn't exist there), making them hard for monitoring/rotation to find. Use absolute paths built from DEPLOY_DIR, e.g. `path.join(DEPLOY_DIR, 'logs/backend-error.log')` (require('path') at the top).","suggestion_code":null,"existing_code":" error_file: 'logs/backend-error.log',\n out_file: 'logs/backend-out.log',"}
{"path":"ecosystem.config.cjs","start_line":28,"end_line":29,"category":"bug","severity":"low","content":"No `kill_timeout` is configured. On `pm2 startOrReload` (and restarts), PM2 sends SIGINT and then SIGKILL after a short default grace period (~1.6s), which can abort in-flight HTTP requests and cut DB connections before the Nest app finishes graceful shutdown. Consider adding `kill_timeout: 5000` (and optionally `listen_timeout`) and ensure the app handles SIGINT to drain connections cleanly during deploys.","suggestion_code":null,"existing_code":" autorestart: true,\n watch: false,"}
{"path":"serve-proxy.js","start_line":40,"end_line":46,"category":"bug","severity":"high","content":"`API_TARGET` is read from the environment but never actually used — the proxy hardcodes `127.0.0.1:3000` (hostname, port, and host header are all hardcoded in three places). If `API_TARGET` points to a different host/port, requests are still forwarded to 127.0.0.1:3000, silently breaking the configuration. Parse the target once with `new URL(API_TARGET)` and derive hostname/port/host from it (note `http.request` also can't handle an `https://` target).","suggestion_code":" const target = new URL(API_TARGET);\n const opts = {\n hostname: target.hostname,\n port: target.port || (target.protocol === 'https:' ? 443 : 80),\n path: (target.pathname === '/' ? '' : target.pathname) + req.url,\n method: req.method,\n headers: { ...req.headers, host: target.host },\n };","existing_code":" const opts = {\n hostname: '127.0.0.1',\n port: 3000,\n path: req.url,\n method: req.method,\n headers: { ...req.headers, host: '127.0.0.1:3000' },\n };"}
{"path":"serve-proxy.js","start_line":29,"end_line":34,"category":"bug","severity":"medium","content":"The catch-all fallback swallows every read error (ENOENT, EACCES, etc.) and returns `index.html` with a 200 for ANY missing file — including missing `.js`/`.css` assets. This serves HTML where a script module was expected (breaking module loading and masking real deployment problems) and never produces a 404. Worse, if `apps/admin/dist/index.html` itself is missing, `fs.readFileSync` throws inside the catch block, propagates out of the request handler, and crashes the process. Only fall back for navigation requests (no file extension / `Accept: text/html`) and return a proper 404 for missing assets; guard the index.html read as well.","suggestion_code":null,"existing_code":" } catch {\n // SPA fallback: return index.html\n const index = fs.readFileSync(path.join(STATIC_DIR, 'index.html'));\n res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });\n res.end(index);\n }"}
{"path":"serve-proxy.js","start_line":26,"end_line":27,"category":"performance","severity":"medium","content":"`fs.readFileSync` blocks the Node event loop on every request. Under load, all concurrent requests are serialized behind synchronous file I/O, and large files are fully buffered in memory. Use async `fs.promises.readFile` or stream the file with `fs.createReadStream`.","suggestion_code":" const stream = fs.createReadStream(filePath);\n res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' });\n stream.pipe(res);","existing_code":" const content = fs.readFileSync(filePath);\n res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' });"}
{"path":"serve-proxy.js","start_line":47,"end_line":50,"category":"bug","severity":"medium","content":"Two proxy robustness issues: (1) `proxyRes.headers` is forwarded verbatim, including hop-by-hop headers (`connection`, `keep-alive`, `transfer-encoding`, `upgrade`, …). Because the response is piped, Node sets its own `transfer-encoding`/`content-length`, which can conflict with the forwarded headers and cause protocol errors or truncated responses — strip hop-by-hop headers before `writeHead`. (2) `proxy.on('error')` can fire after headers have already been written (mid-stream upstream failure or client disconnect), so the `res.writeHead(502)` call will throw uncaught; guard with `res.headersSent`, add a timeout for the upstream connection, and handle errors from `req.pipe(proxy)` / `proxyRes.pipe(res)`.","suggestion_code":null,"existing_code":" const proxy = http.request(opts, (proxyRes) => {\n res.writeHead(proxyRes.statusCode, proxyRes.headers);\n proxyRes.pipe(res);\n });"}
{"path":"serve-proxy.js","start_line":60,"end_line":62,"category":"security","severity":"low","content":"Because every request URL is absolute (starts with `/`), `path.normalize` already clips leading `..` at the root, so the `replace(/^((\\.\\.(\\/|\\\\|$))+)/, '')` regex never matches anything — it's misleading dead code. Percent-encoded traversal (`%2e%2e%2f`) is neither decoded nor checked, and `path.join`/`normalize` semantics are platform-dependent. Decode the URL with `decodeURIComponent` and explicitly verify the resolved path stays inside `STATIC_DIR` (e.g., `path.resolve(...)` and `startsWith(STATIC_DIR + path.sep)`), returning 403/404 otherwise; this also fixes serving filenames containing spaces/encoded characters.","suggestion_code":null,"existing_code":" const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];\n const safePath = path.normalize(urlPath).replace(/^(\\.\\.(\\/|\\\\|$))+/, '');\n serveStatic(res, path.join(STATIC_DIR, safePath));"}
{"path":"migrate.sh","start_line":19,"end_line":19,"category":"security","severity":"high","content":"密码以 `-p\"${MYSQL_PASS}\"` 形式拼在命令行参数中,会出现在 `ps aux` 进程列表里,同机其他用户可直接看到明文密码。建议改用 `docker compose exec -T -e MYSQL_PWD=\"${MYSQL_PASS}\" mysql mysql -u root ...` 方式传入,避免密码暴露在进程列表中。","suggestion_code":"gunzip -c \"$DUMP\" | docker compose exec -T -e MYSQL_PWD=\"${MYSQL_PASS}\" mysql mysql -u root dorm_billing","existing_code":"gunzip -c \"$DUMP\" | docker compose exec -T mysql mysql -u root -p\"${MYSQL_PASS}\" dorm_billing"}
{"path":"migrate.sh","start_line":16,"end_line":16,"category":"security","severity":"medium","content":"硬编码默认密码 `gongxue_2024` 作为 `.env` 缺失时的回退值。该凭据一旦随代码库泄露即等于泄露,且会静默使用弱口令/已知口令部署。建议强制要求 MYSQL_ROOT_PASSWORD 从 `.env` 或环境变量注入,缺失时直接报错退出,而不是回退到默认值。","suggestion_code":null,"existing_code":"MYSQL_PASS=\"${MYSQL_ROOT_PASSWORD:-gongxue_2024}\""}
{"path":"migrate.sh","start_line":37,"end_line":37,"category":"security","severity":"high","content":"脚本最后把含明文密码的完整命令 `-p${MYSQL_PASS}` echo 到终端/日志,直接泄露凭据(也会进入 CI/终端历史)。建议只输出不含密码的验证命令,例如提示使用 MYSQL_PWD 或提示输入密码。","suggestion_code":"echo \" docker compose exec -e MYSQL_PWD=<password> mysql mysql -u root gongxue -e 'SELECT COUNT(*) FROM students'\"","existing_code":"echo \" docker compose exec mysql mysql -u root -p${MYSQL_PASS} gongxue -e 'SELECT COUNT(*) FROM students'\""}
{"path":"migrate.sh","start_line":25,"end_line":25,"category":"bug","severity":"medium","content":"固定 `sleep 8` 无法保证 TypeORM synchronize 已完成建表:机器较慢或应用启动耗时较长时,脚本会提前进入步骤 3导致 migrate-legacy.sql 因目标表不存在而失败(且失败后处于不一致状态)。建议改为轮询等待就绪条件(如循环检查目标表 `gongxue.students` 是否存在,或等待应用健康检查通过)再继续。","suggestion_code":null,"existing_code":"sleep 8"}
{"path":"migrate.sh","start_line":29,"end_line":29,"category":"bug","severity":"medium","content":"`migrate-legacy.sql` 通过 stdin 直接执行,未包裹在事务中;若中途失败,数据将部分迁移、库处于不一致状态,且脚本没有备份/回滚机制。建议在 SQL 内显式使用 `START TRANSACTION ... COMMIT`(确认目标表为 InnoDB或迁移前先备份目标库。","suggestion_code":null,"existing_code":"docker compose exec -T mysql mysql -u root -p\"${MYSQL_PASS}\" < migrate-legacy.sql"}
{"path":"migrate.sh","start_line":24,"end_line":24,"category":"bug","severity":"medium","content":"脚本没有 trap 清理逻辑:若在 `pm2 start` 之后、`pm2 stop` 之前任一步失败,`set -e` 直接退出会留下运行中的应用;反之在 `pm2 stop` 后失败,应用会一直处于停止状态。另外,若 backend 已在运行(非严格首次运行场景),`pm2 start` 会因重复启动失败直接终止脚本。建议用 trap 在失败时恢复应用状态,并在 start 前先检测/处理已运行实例。","suggestion_code":null,"existing_code":"DB_SYNCHRONIZE=true pm2 start ecosystem.config.cjs --only gongxue-backend"}
{"path":"migrate.sh","start_line":29,"end_line":29,"category":"maintainability","severity":"low","content":"`migrate-legacy.sql` 使用相对路径,脚本从其他工作目录执行时会找不到文件。建议在脚本开头 `cd \"$(dirname \"$0\")\"` 或使用相对脚本目录的绝对路径,保证脚本在任意目录下可运行。","suggestion_code":null,"existing_code":"docker compose exec -T mysql mysql -u root -p\"${MYSQL_PASS}\" < migrate-legacy.sql"}
{"path":"deploy.sh","start_line":34,"end_line":39,"category":"bug","severity":"high","content":"Remote commands run without `set -e` (only the local shell enables `set -euo pipefail`). The ssh exit code is determined solely by the last remote command (`pm2 status`), so if `npm ci` or `npm run migration:run -w @gongxue/server` fails, the script still reloads PM2 and prints \"部署完成!\" — a failed migration is silently swallowed and the deploy is reported as successful. Add `set -euo pipefail` at the start of the remote command string (right after `cd ${REMOTE_DIR}`) or check each step's exit code explicitly.","suggestion_code":null,"existing_code":" echo '执行数据库迁移...'\n npm run migration:run -w @gongxue/server\n echo 'PM2 重载...'\n pm2 startOrReload ecosystem.config.cjs --update-env\n pm2 save\n pm2 status"}
{"path":"deploy.sh","start_line":17,"end_line":24,"category":"security","severity":"high","content":"`--delete` will remove any server-only files that are not excluded. The repo contains no committed `.env` (only `.env.example`), so the production `.env` created manually on the server would be deleted on every deploy, and a local dev `.env` (if present) would overwrite the production one — leaking/erasing secrets. Add `--exclude='.env'` / `--exclude='.env.*'` and exclude any other server-only runtime data (e.g. `uploads/`) that must survive deploys.","suggestion_code":null,"existing_code":"rsync -avz --delete \\\n --exclude='node_modules' \\\n --exclude='.git' \\\n --exclude='*.db' \\\n --exclude='.DS_Store' \\\n --exclude='logs/' \\\n --exclude='.turbo/' \\\n ./ \"${SSH_HOST}:${REMOTE_DIR}/\""}
{"path":"deploy.sh","start_line":30,"end_line":33,"category":"bug","severity":"high","content":"`npm ci` runs only when `node_modules` does not exist on the server. On subsequent deploys, changes to `package.json`/`package-lock.json` (new or removed dependencies) are never installed because `node_modules` is excluded from rsync and already present remotely — production can run with stale dependencies while the local build (used to produce the deployed bundle) was built with new ones. Always run `npm ci --omit=dev` on the server, or trigger it by comparing the remote and local `package-lock.json` checksums.","suggestion_code":null,"existing_code":" if [ ! -d node_modules ]; then\n echo '首次部署,安装依赖...'\n npm ci --omit=dev\n fi"}
{"path":"deploy.sh","start_line":44,"end_line":44,"category":"bug","severity":"low","content":"The fallback `|| curl -s ifconfig.me` is ineffective: `hostname -I 2>/dev/null | awk \"{print $1}\"` is a pipeline whose exit status is that of `awk`, which still exits 0 (with empty output) when `hostname -I` fails. The script then prints `http://` with no host. Capture the IP into a variable, validate it is non-empty, and only then fall back to curl.","suggestion_code":null,"existing_code":"echo \"访问: http://$(ssh \"${SSH_HOST}\" 'hostname -I 2>/dev/null | awk \"{print \\$1}\" || curl -s ifconfig.me')\""}
{"path":"oxlint.config.ts","start_line":10,"end_line":12,"category":"maintainability","severity":"low","content":"The React version is hardcoded to '19.0.0' with no match found in any package.json in this repo. This setting can silently drift from the actual dependency version, causing react-plugin rules to evaluate against a stale version. Prefer `version: 'detect'` (auto-detect from package.json) or keep this value in sync with the real dependency.","suggestion_code":null,"existing_code":" react: {\n version: '19.0.0',\n },"}
{"path":"oxlint.config.ts","start_line":6,"end_line":6,"category":"maintainability","severity":"low","content":"Globally disabling `typescript/no-explicit-any` removes the type-safety guardrail for the entire codebase, contradicting the project's TypeScript quality requirement of avoiding `any`. If `any` is only needed in a few places, keep the rule enabled and use targeted `// eslint-disable` comments (or annotate the `any` usage with a justification) instead of turning it off globally.","suggestion_code":null,"existing_code":" 'typescript/no-explicit-any': 'off',"}
{"path":".gitea/workflows/deploy.yml","start_line":53,"end_line":56,"category":"security","severity":"high","content":"`--delete` will remove any files on the server that are not present in the source tree. Runtime config such as `.env` / `.env.*` (typically gitignored and thus absent from the repo) will be silently deleted on every deploy, breaking DB credentials and other environment settings. Add `--exclude='.env'` (and `--exclude='.env.*'`) or keep server-side config outside REMOTE_DIR.","suggestion_code":null,"existing_code":" rsync -avz --delete \\\n --exclude='node_modules' \\\n --exclude='.git' \\\n --exclude='*.db' \\"}
{"path":".gitea/workflows/deploy.yml","start_line":69,"end_line":72,"category":"bug","severity":"high","content":"Dependencies are only installed when `node_modules` does not exist. On subsequent deploys, changes to `package.json` / `package-lock.json` will never be installed, so the server keeps running stale dependency versions after the code has been updated. Run `npm ci --omit=dev` unconditionally (or compare a lockfile hash / mtime) instead of gating on directory existence.","suggestion_code":null,"existing_code":" if [ ! -d node_modules ]; then\n echo '首次部署,安装生产依赖...'\n npm ci --omit=dev\n fi"}
{"path":".gitea/workflows/deploy.yml","start_line":16,"end_line":17,"category":"bug","severity":"medium","content":"No `concurrency` control is defined for this workflow. Two manual dispatches run at the same time can race on rsync `--delete`, run database migrations concurrently, and interfere with PM2 reload, potentially corrupting the deployed directory. Add a `concurrency` group (e.g. with `cancel-in-progress: true`) keyed to the deploy target.","suggestion_code":null,"existing_code":"on:\n workflow_dispatch:"}