fix: bound leadership dashboard filtering
This commit is contained in:
@@ -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.
|
||||
@@ -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'/);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -405,6 +405,6 @@
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<script src="app.js?v=20260902-dashboard-mobile-share-1"></script>
|
||||
<script src="app.js?v=20260902-dashboard-filter-fix-2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<PublicOperationsDashboardPage> {
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
};
|
||||
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<string, unknown>[];
|
||||
const searchValuesByTask = new Map<string, string[]>();
|
||||
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<string, unknown>[]) {
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>[];
|
||||
};
|
||||
|
||||
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<string, unknown>[])
|
||||
.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<string, unknown>[];
|
||||
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<string, string[]>();
|
||||
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<string, unknown>[]) {
|
||||
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<string, unknown>[]).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<string, unknown>[]).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(
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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\(\)\}`/);
|
||||
|
||||
Reference in New Issue
Block a user