From 3062ed5f0400d1b90319b58b79cd50445b94ad8e Mon Sep 17 00:00:00 2001 From: inman Date: Wed, 2 Sep 2026 14:59:18 +0800 Subject: [PATCH] fix: bound leadership dashboard filtering --- .../tasks/20260902-kanban-filter-7e3a91c4.md | 65 +++ .../app-operations-dashboard.test.mjs | 49 ++- LianSyn-platform/app.js | 14 +- LianSyn-platform/index.html | 2 +- control-plane/src/server.ts | 40 +- control-plane/src/task-service.ts | 390 +++++++++++------- .../test/account-authorization.test.ts | 2 +- control-plane/test/control-plane.test.ts | 2 +- 8 files changed, 389 insertions(+), 175 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260902-kanban-filter-7e3a91c4.md diff --git a/.project-docs/30-worklog/tasks/20260902-kanban-filter-7e3a91c4.md b/.project-docs/30-worklog/tasks/20260902-kanban-filter-7e3a91c4.md new file mode 100644 index 0000000..c379d0a --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-kanban-filter-7e3a91c4.md @@ -0,0 +1,65 @@ +# Task: Fix kanban table filters + +## Identity + +- Task ID: 20260902-kanban-filter-7e3a91c4 +- Mode: Feature +- Branch: main +- Worktree: /Users/inmanx/Documents/lwltAPI +- Base commit: d034f649c4e7c5d0856f22053b92d2e5a63be5eb +- Owner: codex +- Status: Ready for integration + +## Scope + +- Repair leadership-dashboard table filtering when a business type and/or keyword is submitted. +- Remove redundant full-range hydration and connection amplification from both ordinary loads and keyword queries. +- Bound database work and propagate HTTP disconnects into the dashboard read pipeline. +- Change the dashboard's frontend, API, and service default page size to 20 rows. +- Add explicit in-flight and timeout feedback plus focused regression coverage. + +## Intent And Constraints + +- Preserve the existing read-only leadership authorization, date/actor/status semantics, historical business-type inference, and business-facing result projection. +- Keep full-fidelity task output for rows returned on the current page while using lean candidate projections for filtering and aggregation. +- Do not modify ERP behavior, task lifecycle state, permissions, runtime services, deployment, or canonical project memory in this Feature task. +- Coordinate around the ready-for-integration dashboard-metrics task: its display-only summary-card semantics are independent of this filter-form/API repair, although both touch focused dashboard regression files. +- Do not read `.env`, restart the running service, or perform external writes without separate user authorization. + +## Outcome + +- Reproduced the reported failure in the already authenticated local dashboard: submitting the form sent the expected `from`, `to`, `status`, `business_route_id`, `search`, `limit`, and `offset` parameters, but the request exceeded the 15-second client deadline and was aborted while unrelated health and task-list requests remained responsive. +- Confirmed from privacy-safe request diagnostics that dashboard requests alone were accumulating without completion while health, readiness, and task-list endpoints remained responsive. The old implementation could use multiple pool connections per request, hydrate the same candidates repeatedly, and continue database work after the browser's 15-second timeout. +- Pushed the selected business type into candidate SQL while retaining null-route legacy rows for instruction/operation inference. +- Replaced pool-level parallel reads with one read-only transaction per dashboard request. The transaction uses one checked-out connection, a 5-second PostgreSQL statement timeout, and a 9-second service query budget. +- Propagated request/reply disconnects through an `AbortSignal`, stopping all subsequent query and projection stages when the client has gone away. Active database statements remain bounded by the server-side timeout. +- Removed the ordinary-load classification requery and the keyword path's candidate rehydration query. Ordinary loads fetch encrypted original text only for legacy null-route candidates; keyword loads use the lean persisted receipt/error projection and load historical messages only for relevant candidates not already matched by task fields. +- Kept full-fidelity detail hydration after pagination and reduced that page from 50 to 20 rows consistently in the browser, API schema, and service fallback. +- Added privacy-safe per-stage and aggregate dashboard timing/count diagnostics without recording filter values or business content. +- Added explicit filter-form busy state, disabled the query button during an in-flight request, exposed actionable client and server timeout messages, and advanced the JavaScript cache token. +- Added a focused regression that protects single-connection read-only execution, database and request bounds, business prefiltering, reduced keyword hydration, page-only detail hydration, 20-row defaults, form busy state, and timeout feedback. +- Preserved read-only dashboard authorization, historical business-type inference, result/status classification, task pagination, and ERP/task lifecycle behavior. + +## Verification + +- Passed: `git diff --check`. +- Passed: `node --check LianSyn-platform/app.js`. +- Passed: focused dashboard regression, 5/5 tests. +- Passed: `node --run check` (TypeScript no-emit check). +- Passed: `node --run test:control-plane`, 156/156 tests. +- Passed: `node --run check:repo`, 10/10 tests. +- Passed: `node --run test:legacy`, 265/265 tests. +- Passed: `node --run build`. +- Runtime post-change verification was not run because the currently running control-plane process must be restarted to load the TypeScript change, and restart requires separate user authorization. + +## Follow-ups + +- During integration, reconcile the focused dashboard test, `app.js`, `index.html`, and cache token with ready task `20260902-dashboard-metrics-static-a91c`; its display-only metric-card behavior is semantically independent and should be preserved. +- After integration and an explicitly authorized service restart, repeat the captured business-type-plus-keyword query and verify it completes before the client deadline with a filtered total and matching table rows. + +## Promotion Candidates + +- Target canonical document: `.project-docs/10-architecture/data-flow.md` during Integration Gate review. +- Proposal: document the leadership-dashboard list as a bounded single-connection read pipeline—SQL prefilter and lean candidate projection, historical-message hydration only for unmatched search candidates, then full projection only for the paginated result rows. +- Evidence: browser network reproduction, privacy-safe server request diagnostics, focused regression, statement/request resource guards, and the full repository verification recorded above. +- Semantic conflicts: none with AUTH-001, the business-facing dashboard projection, or the concurrent display-only summary-card task. diff --git a/LianSyn-platform/app-operations-dashboard.test.mjs b/LianSyn-platform/app-operations-dashboard.test.mjs index 3896a23..e591b0d 100644 --- a/LianSyn-platform/app-operations-dashboard.test.mjs +++ b/LianSyn-platform/app-operations-dashboard.test.mjs @@ -2,11 +2,12 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -const [app, index, styles, taskService] = await Promise.all([ +const [app, index, styles, taskService, server] = await Promise.all([ readFile(new URL('./app.js', import.meta.url), 'utf8'), readFile(new URL('./index.html', import.meta.url), 'utf8'), readFile(new URL('./styles.css', import.meta.url), 'utf8'), - readFile(new URL('../control-plane/src/task-service.ts', import.meta.url), 'utf8') + readFile(new URL('../control-plane/src/task-service.ts', import.meta.url), 'utf8'), + readFile(new URL('../control-plane/src/server.ts', import.meta.url), 'utf8') ]); test('dashboard summary cards share one visual treatment and expose an accessible selected state', () => { @@ -59,3 +60,47 @@ test('phone dashboard turns metrics and task rows into touch-friendly cards', () assert.match(app, /document\.body\.classList\.add\('operations-dashboard-detail-open'\)/); assert.match(app, /document\.body\.classList\.remove\('operations-dashboard-detail-open'\)/); }); + +test('table filters use a bounded single-connection read and default to 20 rows', () => { + const dashboardQuery = taskService.slice( + taskService.indexOf('async listOperationsDashboard('), + taskService.indexOf('async getOperationsDashboardTask(') + ); + const dashboardRoute = server.slice( + server.indexOf("app.get('/api/operations-dashboard'"), + server.indexOf("app.get('/api/operations-dashboard/tasks/:taskId'") + ); + assert.match(dashboardQuery, /businessRouteId === 'unclassified'[\s\S]*?t\.business_route_id IS NULL/); + assert.match(dashboardQuery, /t\.business_route_id = \$\$\{params\.length\} OR t\.business_route_id IS NULL/); + assert.match(dashboardQuery, /const taskSearchSelect = `[\s\S]*?t\.success_receipt = '\{\}'::jsonb[\s\S]*?END AS execution_result/); + assert.match(dashboardQuery, /withTransaction\(this\.config/); + assert.match(dashboardQuery, /SET TRANSACTION READ ONLY/); + assert.match(dashboardQuery, /SET LOCAL statement_timeout/); + assert.match(dashboardQuery, /SELECT \$\{search \? taskSearchSelect : taskCandidateSelect\}/); + assert.match(dashboardQuery, /CASE WHEN t\.business_route_id IS NULL THEN t\.original_text_ciphertext ELSE NULL END/); + assert.match(dashboardQuery, /'search_messages'/); + assert.match(dashboardQuery, /filter\(\(\{ directSearchMatch \}\) => !directSearchMatch\)/); + assert.doesNotMatch(dashboardQuery, /const pool = getPool\(this\.config\)/); + assert.doesNotMatch(dashboardQuery, /Promise\.all/); + assert.doesNotMatch(dashboardQuery, /loadSearchRows|classificationResult/); + assert.match(dashboardQuery, /if \(pageMatches\.length\)[\s\S]*?loadDetailedRows\(pageMatches\.map/); + assert.match(dashboardQuery, /options\.limit \|\| 20/); + assert.match(app, /const OPERATIONS_DASHBOARD_PAGE_SIZE = 20/); + assert.match(server, /operationsDashboardQuerySchema[\s\S]*?limit:[^\n]+default\(20\)/); + assert.match(dashboardRoute, /new AbortController\(\)/); + assert.match(dashboardRoute, /request\.raw\.once\('aborted', abortRequest\)/); + assert.match(dashboardRoute, /signal: controller\.signal/); + assert.match(dashboardRoute, /request\.raw\.off\('aborted', abortRequest\)/); + assert.match(dashboardQuery, /operations_dashboard_query_timeout/); + + const sync = app.slice( + app.indexOf('async function syncOperationsDashboard()'), + app.indexOf('async function loadOperationsDashboardTask(') + ); + assert.match(sync, /filterForm\.setAttribute\('aria-busy', 'true'\)/); + assert.match(sync, /queryButton\.disabled = true/); + assert.match(sync, /filterForm\.removeAttribute\('aria-busy'\)/); + assert.match(sync, /queryButton\.disabled = false/); + assert.match(app, /看板请求超时,请稍后重试;若正在筛选,请缩短日期范围或增加筛选条件/); + assert.match(app, /error\?\.errorCode === 'operations_dashboard_query_timeout'/); +}); diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 7038943..5bc8309 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -14,7 +14,7 @@ const IS_MANAGEMENT_PAGE = IS_ADMIN_PAGE || IS_OPERATIONS_DASHBOARD_PAGE; const IS_TASK_PAGE = !IS_MANAGEMENT_PAGE; const HOME_TASK_LIMIT = 10; const HISTORY_TASK_PAGE_SIZE = 24; -const OPERATIONS_DASHBOARD_PAGE_SIZE = 50; +const OPERATIONS_DASHBOARD_PAGE_SIZE = 20; const DEFAULT_API_TIMEOUT_MS = 15_000; const AUTH_API_TIMEOUT_MS = 10_000; const RESULT_API_TIMEOUT_MS = 20_000; @@ -1315,6 +1315,12 @@ function operationsDashboardReadableResult(value, outcomeKind) { function operationsDashboardSafeError(error, fallback = '看板暂时无法更新,请稍后再试。') { const message = String(error?.message || '').trim(); + if (error?.errorCode === 'operations_dashboard_query_timeout') { + return '看板查询耗时过长,请缩短日期范围或增加筛选条件后重试。'; + } + if (error?.code === 'request_timeout' || /^请求超时/.test(message)) { + return '看板请求超时,请稍后重试;若正在筛选,请缩短日期范围或增加筛选条件。'; + } if (/^(请选择有效的开始与结束日期|结束日期不能早于开始日期|单次最多查询|关键词查询范围)/.test(message)) { return message; } @@ -1621,6 +1627,10 @@ async function syncOperationsDashboard() { renderOperationsDashboardTasks(); const message = $('#operationsDashboardMessage'); const refreshButton = $('#operationsDashboardRefresh'); + const filterForm = $('#operationsDashboardFilters'); + const queryButton = filterForm?.querySelector('button[type="submit"]'); + if (filterForm) filterForm.setAttribute('aria-busy', 'true'); + if (queryButton) queryButton.disabled = true; if (refreshButton) refreshButton.disabled = true; if (message) message.textContent = '正在汇总平台运行数据…'; try { @@ -1648,6 +1658,8 @@ async function syncOperationsDashboard() { if (message) message.textContent = ''; } finally { operationsDashboardBusy = false; + if (filterForm) filterForm.removeAttribute('aria-busy'); + if (queryButton) queryButton.disabled = false; if (refreshButton) refreshButton.disabled = false; renderOperationsDashboardTasks(); } diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index 3cb35c6..e487b0c 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -405,6 +405,6 @@ - + diff --git a/control-plane/src/server.ts b/control-plane/src/server.ts index 52197ab..0297b73 100644 --- a/control-plane/src/server.ts +++ b/control-plane/src/server.ts @@ -186,7 +186,7 @@ const operationsDashboardQuerySchema = z.object({ business_route_id: z.string().trim().max(120).optional(), status: z.enum(['all', 'active', 'completed', 'attention', 'failed', 'cancelled', 'archived']).default('all'), search: z.string().trim().max(200).optional(), - limit: z.coerce.number().int().min(1).max(100).default(50), + limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).max(1_000_000).default(0) }); @@ -1357,19 +1357,33 @@ export async function buildServer({ setAuthNoStore(reply); const session = await requireLeadershipSession(request); const query = operationsDashboardQuerySchema.parse(request.query || {}); - return { - ok: true, - ...(await tasks.listOperationsDashboard(contextFor(session, request), { - from: query.from, - to: query.to, - actorUserId: query.actor_user_id, - businessRouteId: query.business_route_id, - status: query.status, - search: query.search, - limit: query.limit, - offset: query.offset - })) + const controller = new AbortController(); + const abortRequest = () => controller.abort(); + const abortClosedReply = () => { + if (!reply.raw.writableEnded) abortRequest(); }; + request.raw.once('aborted', abortRequest); + reply.raw.once('close', abortClosedReply); + if (request.raw.aborted) abortRequest(); + try { + return { + ok: true, + ...(await tasks.listOperationsDashboard(contextFor(session, request), { + from: query.from, + to: query.to, + actorUserId: query.actor_user_id, + businessRouteId: query.business_route_id, + status: query.status, + search: query.search, + limit: query.limit, + offset: query.offset, + signal: controller.signal + })) + }; + } finally { + request.raw.off('aborted', abortRequest); + reply.raw.off('close', abortClosedReply); + } }); app.get('/api/operations-dashboard/tasks/:taskId', async (request, reply) => { diff --git a/control-plane/src/task-service.ts b/control-plane/src/task-service.ts index 17cb4a1..6e20de7 100644 --- a/control-plane/src/task-service.ts +++ b/control-plane/src/task-service.ts @@ -1895,6 +1895,9 @@ const OPERATIONS_DASHBOARD_FALLBACK_TYPES = [ { key: 'dashboard_order_update', label: '订单修改', action: 'order_update' } ] as const; +const OPERATIONS_DASHBOARD_QUERY_BUDGET_MS = 9_000; +const OPERATIONS_DASHBOARD_STATEMENT_TIMEOUT_MS = 5_000; + function operationsDashboardFallbackType(value: unknown, fallbackText: unknown) { const operation = operationsDashboardOperationObject(value, fallbackText); const action = text(operation.action).trim(); @@ -1909,13 +1912,6 @@ function operationsDashboardTypeById(value: unknown) { return OPERATIONS_DASHBOARD_FALLBACK_TYPES.find((candidate) => candidate.key === key) || null; } -function operationsDashboardNeedsInstructionForType(routeId: unknown, operation: unknown): boolean { - if (businessRouteById(routeId)) return false; - if (operationsDashboardRouteFromOperation(operation, '')) return false; - const fallback = operationsDashboardFallbackType(operation, ''); - return !fallback || fallback.action === 'arrangement_hotel' || fallback.action === 'passenger_list_import'; -} - function operationsDashboardBusiness( routeId: unknown, instruction?: unknown, @@ -4663,9 +4659,30 @@ export class TaskService { search?: string; limit?: number; offset?: number; + signal?: AbortSignal; } = {} ): Promise { this.requireOperationsDashboardAccess(context); + const requestStartedAt = Date.now(); + const queryDeadlineAt = requestStartedAt + OPERATIONS_DASHBOARD_QUERY_BUDGET_MS; + const timeoutError = () => new TaskError( + 'operations_dashboard_query_timeout', + '看板查询耗时过长,请缩短日期范围或增加筛选条件后重试。', + 503 + ); + const logTimeout = (errorCode: string) => this.logger.warn({ + diagnostic_event: 'operations_dashboard.timeout', + diagnostic_stage: 'operations_dashboard', + request_id: context.requestId, + duration_ms: Date.now() - requestStartedAt, + error_code: errorCode + }, 'operations dashboard timed out'); + const assertQueryAvailable = () => { + if (options.signal?.aborted) { + throw new TaskError('operations_dashboard_request_aborted', '看板请求已取消。', 499); + } + if (Date.now() >= queryDeadlineAt) throw timeoutError(); + }; const now = new Date(); const to = options.to ? new Date(options.to) : now; const from = options.from @@ -4694,25 +4711,23 @@ export class TaskService { if (businessRouteId && businessRouteId !== 'unclassified' && !operationsDashboardTypeById(businessRouteId)) { throw new TaskError('operations_dashboard_business_invalid', '看板业务类型无效。', 400); } + if (businessRouteId === 'unclassified') { + where += ' AND t.business_route_id IS NULL'; + } else if (businessRouteId) { + params.push(businessRouteId); + where += ` AND (t.business_route_id = $${params.length} OR t.business_route_id IS NULL)`; + } const statusCondition = operationsStatusSql(status); if (statusCondition) where += ` AND ${statusCondition}`; const search = text(options.search).trim().toLowerCase(); - const boundedLimit = Math.max(1, Math.min(100, Math.trunc(options.limit || 50))); + const boundedLimit = Math.max(1, Math.min(100, Math.trunc(options.limit || 20))); const boundedOffset = Math.max(0, Math.min(1_000_000, Math.trunc(options.offset || 0))); - const pool = getPool(this.config); const businessOptions = [ ...BUSINESS_ROUTES.map((route) => ({ key: route.routeId, label: route.directive })), ...OPERATIONS_DASHBOARD_FALLBACK_TYPES.map(({ key, label }) => ({ key, label })), { key: 'unclassified', label: '其他任务' } ]; - const actorQuery = () => pool.query( - `SELECT id, username, role, is_active - FROM users - WHERE organization_id = $1 - ORDER BY is_active DESC, username ASC`, - [context.organizationId] - ); const taskBaseSelect = `t.id, t.task_id, t.status, t.business_route_id, t.archived_at, t.created_at, t.updated_at, t.operation, creator.id AS actor_id, creator.username AS actor_username, @@ -4727,154 +4742,217 @@ export class TaskService { AND input_message.task_id = t.id AND input_message.role = 'user' AND input_message.input_source IS DISTINCT FROM 'reparse') AS input_turn_count`; - const loadDetailedRows = async (taskRowIds: string[]) => { - if (!taskRowIds.length) return [] as Record[]; - const detailResult = await pool.query( - `SELECT ${taskDetailSelect} - FROM tasks t - LEFT JOIN users creator ON creator.id = t.created_by - WHERE t.organization_id = $1 - AND t.id = ANY($2::uuid[])`, - [context.organizationId, taskRowIds] - ); - return detailResult.rows as Record[]; - }; + const taskSearchSelect = `${taskBaseSelect}, + t.original_text_ciphertext, t.message, t.success_receipt, t.success_receipt_at, + t.error_summary, t.error_summary_at, + CASE + WHEN t.status = 'completed' + AND (t.success_receipt IS NULL OR t.success_receipt = '{}'::jsonb) + THEN t.execution_result + ELSE NULL + END AS execution_result, + CASE WHEN t.status = 'awaiting_user_input' THEN t.parse_response ELSE NULL END AS parse_response`; + const taskCandidateSelect = `${taskBaseSelect}, + CASE WHEN t.business_route_id IS NULL THEN t.original_text_ciphertext ELSE NULL END AS original_text_ciphertext`; - const [candidateResult, actorResult] = await Promise.all([ - pool.query( - `SELECT ${taskBaseSelect} - FROM tasks t - LEFT JOIN users creator ON creator.id = t.created_by - WHERE ${where} - ORDER BY t.created_at DESC, t.id DESC`, - params - ), - actorQuery() - ]); - if (search && candidateResult.rows.length > 2_000) { - throw new TaskError( - 'operations_dashboard_search_scope_too_large', - '关键词查询范围超过 2000 项任务,请先缩短日期或选择人员、任务类型后再查询。', - 400 - ); - } + try { + assertQueryAvailable(); + return await withTransaction(this.config, async (client) => { + assertQueryAvailable(); + await client.query('SET TRANSACTION READ ONLY'); + await client.query(`SET LOCAL statement_timeout = '${OPERATIONS_DASHBOARD_STATEMENT_TIMEOUT_MS}ms'`); - const candidateRows = candidateResult.rows as Record[]; - const searchValuesByTask = new Map(); - let projectionRows = candidateRows; - if (search && candidateRows.length) { - const taskRowIds = candidateRows.map((row) => text(row.id)).filter(Boolean); - const [detailedRows, messageResult] = await Promise.all([ - loadDetailedRows(taskRowIds), - pool.query( - `SELECT m.task_id, m.content_ciphertext, actor.username AS actor_username - FROM agent_session_messages m - LEFT JOIN users actor ON actor.id = m.actor_user_id - WHERE m.organization_id = $1 - AND m.task_id = ANY($2::uuid[]) - AND m.role = 'user' - AND m.input_source IS DISTINCT FROM 'reparse'`, - [context.organizationId, taskRowIds] - ) - ]); - const detailedById = new Map(detailedRows.map((row) => [text(row.id), row])); - projectionRows = candidateRows.map((row) => detailedById.get(text(row.id)) || row); - for (const row of messageResult.rows as Record[]) { - const key = text(row.task_id); - const values = searchValuesByTask.get(key) || []; - values.push( - text(row.actor_username), - operationsDashboardLeadershipInstructionText( - decryptText(this.config, row.content_ciphertext as string | null | undefined) - ) + const dashboardQuery = async ( + queryStage: string, + sql: string, + queryParams: unknown[] = [] + ) => { + assertQueryAvailable(); + const startedAt = Date.now(); + const result = await client.query(sql, queryParams); + this.logger.info({ + diagnostic_event: 'operations_dashboard.query.completed', + diagnostic_stage: 'operations_dashboard_query', + request_id: context.requestId, + query_stage: queryStage, + duration_ms: Date.now() - startedAt, + row_count: result.rowCount ?? 0 + }, 'operations dashboard query completed'); + assertQueryAvailable(); + return result; + }; + const loadDetailedRows = async (taskRowIds: string[]) => { + if (!taskRowIds.length) return [] as Record[]; + const detailResult = await dashboardQuery( + 'page_details', + `SELECT ${taskDetailSelect} + FROM tasks t + LEFT JOIN users creator ON creator.id = t.created_by + WHERE t.organization_id = $1 + AND t.id = ANY($2::uuid[])`, + [context.organizationId, taskRowIds] + ); + return detailResult.rows as Record[]; + }; + + const candidateResult = await dashboardQuery( + 'candidates', + `SELECT ${search ? taskSearchSelect : taskCandidateSelect} + FROM tasks t + LEFT JOIN users creator ON creator.id = t.created_by + WHERE ${where} + ORDER BY t.created_at DESC, t.id DESC`, + params ); - searchValuesByTask.set(key, values); - } - } else if (candidateRows.length) { - const classificationIds = candidateRows - .filter((row) => operationsDashboardNeedsInstructionForType(row.business_route_id, row.operation)) - .map((row) => text(row.id)) - .filter(Boolean); - if (classificationIds.length) { - const classificationResult = await pool.query( - `SELECT id, original_text_ciphertext - FROM tasks + if (search && candidateResult.rows.length > 2_000) { + throw new TaskError( + 'operations_dashboard_search_scope_too_large', + '关键词查询范围超过 2000 项任务,请先缩短日期或选择人员、任务类型后再查询。', + 400 + ); + } + + const actorResult = await dashboardQuery( + 'actors', + `SELECT id, username, role, is_active + FROM users WHERE organization_id = $1 - AND id = ANY($2::uuid[])`, - [context.organizationId, classificationIds] + ORDER BY is_active DESC, username ASC`, + [context.organizationId] ); - const instructionsById = new Map( - (classificationResult.rows as Record[]) - .map((row) => [text(row.id), row.original_text_ciphertext]) - ); - projectionRows = candidateRows.map((row) => ({ - ...row, - original_text_ciphertext: instructionsById.get(text(row.id)) - })); - } - } + const candidateRows = candidateResult.rows as Record[]; + const projectedCandidates = candidateRows.flatMap((row) => { + const projection = operationsDashboardTaskProjection(this.config, row); + const routeMatches = !businessRouteId + || (businessRouteId === 'unclassified' + ? !projection.task.business_route_id + : projection.task.business_route_id === businessRouteId); + if (!routeMatches) return []; + const directSearchMatch = !search || operationsDashboardSearchMatches(search, [ + projection.task.creator.username, + projection.task.business_route_label, + projection.instruction, + projection.result + ]); + return [{ row, task: projection.task, directSearchMatch }]; + }); + assertQueryAvailable(); + const searchValuesByTask = new Map(); + if (search && projectedCandidates.length) { + const taskRowIds = projectedCandidates + .filter(({ directSearchMatch }) => !directSearchMatch) + .map(({ row }) => text(row.id)) + .filter(Boolean); + if (taskRowIds.length) { + const messageResult = await dashboardQuery( + 'search_messages', + `SELECT m.task_id, m.content_ciphertext, actor.username AS actor_username + FROM agent_session_messages m + LEFT JOIN users actor ON actor.id = m.actor_user_id + WHERE m.organization_id = $1 + AND m.task_id = ANY($2::uuid[]) + AND m.role = 'user' + AND m.input_source IS DISTINCT FROM 'reparse'`, + [context.organizationId, taskRowIds] + ); + for (const row of messageResult.rows as Record[]) { + const key = text(row.task_id); + const values = searchValuesByTask.get(key) || []; + values.push( + text(row.actor_username), + operationsDashboardLeadershipInstructionText( + decryptText(this.config, row.content_ciphertext as string | null | undefined) + ) + ); + searchValuesByTask.set(key, values); + } + } + } - const matched = projectionRows.flatMap((row) => { - const projection = operationsDashboardTaskProjection(this.config, row); - const routeMatches = !businessRouteId - || (businessRouteId === 'unclassified' - ? !projection.task.business_route_id - : projection.task.business_route_id === businessRouteId); - if (!routeMatches) return []; - if (search && !operationsDashboardSearchMatches(search, [ - projection.task.creator.username, - projection.task.business_route_label, - projection.instruction, - projection.result, - ...(searchValuesByTask.get(text(row.id)) || []) - ])) return []; - return [{ row, task: projection.task }]; - }); - const matchedTasks = matched.map(({ task }) => task); - const actors = (actorResult.rows as Record[]).map((row) => ({ - id: text(row.id), - username: text(row.username), - role: taskRole(row.role), - is_active: databaseBoolean(row.is_active) - } satisfies PublicOperationsDashboardActor)); - const summary = operationsDashboardSummaryFromTasks(matchedTasks); - const days = operationsDashboardBreakdownsFromTasks( - matchedTasks, - (task) => ({ key: task.business_day, label: task.business_day }), - (left, right) => right.key.localeCompare(left.key) - ); - const businesses = operationsDashboardBreakdownsFromTasks( - matchedTasks, - (task) => ({ - key: task.business_route_id || 'unclassified', - label: task.business_route_label - }), - (left, right) => right.total - left.total || left.label.localeCompare(right.label, 'zh-CN') - ); - const pageMatches = matched.slice(boundedOffset, boundedOffset + boundedLimit); - let pageTasks = pageMatches.map(({ task }) => task); - if (!search && pageMatches.length) { - const detailedRows = await loadDetailedRows(pageMatches.map(({ row }) => text(row.id))); - const detailedById = new Map(detailedRows.map((row) => [text(row.id), row])); - pageTasks = pageMatches.map(({ row, task }) => { - const detailed = detailedById.get(text(row.id)); - return detailed ? operationsDashboardTaskProjection(this.config, detailed).task : task; + assertQueryAvailable(); + const matched = projectedCandidates.filter(({ row, directSearchMatch }) => ( + directSearchMatch + || operationsDashboardSearchMatches(search, searchValuesByTask.get(text(row.id)) || []) + )); + assertQueryAvailable(); + const matchedTasks = matched.map(({ task }) => task); + const actors = (actorResult.rows as Record[]).map((row) => ({ + id: text(row.id), + username: text(row.username), + role: taskRole(row.role), + is_active: databaseBoolean(row.is_active) + } satisfies PublicOperationsDashboardActor)); + const summary = operationsDashboardSummaryFromTasks(matchedTasks); + const days = operationsDashboardBreakdownsFromTasks( + matchedTasks, + (task) => ({ key: task.business_day, label: task.business_day }), + (left, right) => right.key.localeCompare(left.key) + ); + const businesses = operationsDashboardBreakdownsFromTasks( + matchedTasks, + (task) => ({ + key: task.business_route_id || 'unclassified', + label: task.business_route_label + }), + (left, right) => right.total - left.total || left.label.localeCompare(right.label, 'zh-CN') + ); + const pageMatches = matched.slice(boundedOffset, boundedOffset + boundedLimit); + let pageTasks = pageMatches.map(({ task }) => task); + if (pageMatches.length) { + const detailedRows = await loadDetailedRows(pageMatches.map(({ row }) => text(row.id))); + const detailedById = new Map(detailedRows.map((row) => [text(row.id), row])); + pageTasks = pageMatches.map(({ row, task }) => { + const detailed = detailedById.get(text(row.id)); + return detailed ? operationsDashboardTaskProjection(this.config, detailed).task : task; + }); + } + assertQueryAvailable(); + this.logger.info({ + diagnostic_event: 'operations_dashboard.completed', + diagnostic_stage: 'operations_dashboard', + request_id: context.requestId, + duration_ms: Date.now() - requestStartedAt, + candidate_count: candidateRows.length, + matched_count: matchedTasks.length, + page_count: pageTasks.length, + keyword_filter: Boolean(search), + business_filter: Boolean(businessRouteId), + actor_filter: Boolean(actorUserId), + status_filter: status !== 'all' + }, 'operations dashboard completed'); + return { + summary, + users: operationsDashboardUsersFromTasks(matchedTasks), + actors, + days, + businesses, + business_options: businessOptions, + tasks: pageTasks, + range: { from: from.toISOString(), to: to.toISOString() }, + total: matchedTasks.length, + offset: boundedOffset, + limit: boundedLimit, + has_more: boundedOffset + pageTasks.length < matchedTasks.length + }; }); + } catch (error) { + if (error instanceof TaskError) { + if (error.code === 'operations_dashboard_query_timeout') logTimeout(error.code); + throw error; + } + const databaseCode = error && typeof error === 'object' && 'code' in error + ? text((error as { code?: unknown }).code) + : ''; + const errorMessage = error instanceof Error ? error.message : text(error); + if (options.signal?.aborted) { + throw new TaskError('operations_dashboard_request_aborted', '看板请求已取消。', 499); + } + if (databaseCode === '57014' || /timeout exceeded when trying to connect|query read timeout/i.test(errorMessage)) { + logTimeout(databaseCode || 'database_timeout'); + throw timeoutError(); + } + throw error; } - return { - summary, - users: operationsDashboardUsersFromTasks(matchedTasks), - actors, - days, - businesses, - business_options: businessOptions, - tasks: pageTasks, - range: { from: from.toISOString(), to: to.toISOString() }, - total: matchedTasks.length, - offset: boundedOffset, - limit: boundedLimit, - has_more: boundedOffset + pageTasks.length < matchedTasks.length - }; } async getOperationsDashboardTask( diff --git a/control-plane/test/account-authorization.test.ts b/control-plane/test/account-authorization.test.ts index 3644c91..3b50a2b 100644 --- a/control-plane/test/account-authorization.test.ts +++ b/control-plane/test/account-authorization.test.ts @@ -135,7 +135,6 @@ test('operations dashboard is leadership-gated, business-facing, searchable, and assert.match(tasks, /operations_dashboard_search_scope_too_large/); assert.match(tasks, /function operationsDashboardRouteFromInstruction/); assert.match(tasks, /function operationsDashboardRouteFromOperation/); - assert.match(tasks, /function operationsDashboardNeedsInstructionForType/); assert.match(tasks, /if \(search && candidateResult\.rows\.length > 2_000\)/); assert.match(tasks, /OPERATIONS_DASHBOARD_FALLBACK_TYPES/); assert.match(tasks, /dashboard_order_delete/); @@ -173,6 +172,7 @@ test('operations dashboard is leadership-gated, business-facing, searchable, and assert.match(server, /app\.get\('\/api\/operations-dashboard'[\s\S]+requireLeadershipSession\(request\)/); assert.match(server, /business_route_id: z\.string\(\)\.trim\(\)\.max\(120\)\.optional\(\)/); assert.match(server, /businessRouteId: query\.business_route_id/); + assert.match(server, /signal: controller\.signal/); assert.match(server, /app\.get\('\/api\/operations-dashboard\/tasks\/:taskId'[\s\S]+requireLeadershipSession\(request\)/); assert.match(server, /read_only: true/); assert.doesNotMatch(server, /app\.(?:post|put|patch|delete)\('\/api\/operations-dashboard/); diff --git a/control-plane/test/control-plane.test.ts b/control-plane/test/control-plane.test.ts index 07b1e64..23c5ce5 100644 --- a/control-plane/test/control-plane.test.ts +++ b/control-plane/test/control-plane.test.ts @@ -1226,7 +1226,7 @@ test('operator page has a login gate and uses the durable task API', async () => assert.match(index, /id="loginPanel"/); assert.match(index, /id="workbench"[^>]*hidden/); assert.match(index, /styles\.css\?v=20260902-dashboard-mobile-share-1/); - assert.match(index, /app\.js\?v=20260902-dashboard-mobile-share-1/); + assert.match(index, /app\.js\?v=20260902-dashboard-filter-fix-2/); assert.match(index, /id="statusDetailsPopover"/); assert.match(index, /id="statusDetailsRefresh"/); assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);