diff --git a/LianSyn-platform/app-operations-dashboard.test.mjs b/LianSyn-platform/app-operations-dashboard.test.mjs index fdfa151..689e6de 100644 --- a/LianSyn-platform/app-operations-dashboard.test.mjs +++ b/LianSyn-platform/app-operations-dashboard.test.mjs @@ -80,6 +80,9 @@ test('table filters use a bounded single-connection read and default to 20 rows' 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\.assigned_user_id IS NOT NULL[\s\S]*?t\.source IN \('manual', 'agentbus'\)/); + assert.match(dashboardQuery, /actorUserId[\s\S]*?t\.assigned_user_id = \$\$\{params\.length\}/); + assert.match(dashboardQuery, /LEFT JOIN users assignee ON assignee\.id = t\.assigned_user_id/); 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/); @@ -102,6 +105,11 @@ test('table filters use a bounded single-connection read and default to 20 rows' assert.match(dashboardRoute, /request\.raw\.off\('aborted', abortRequest\)/); assert.match(dashboardQuery, /operations_dashboard_query_timeout/); + const dashboardDetail = taskService.slice(taskService.indexOf('async getOperationsDashboardTask(')); + assert.match(dashboardDetail, /t\.assigned_user_id IS NOT NULL/); + assert.match(dashboardDetail, /t\.source IN \('manual', 'agentbus'\)/); + assert.doesNotMatch(dashboardDetail, /t\.source = 'manual'/); + const sync = app.slice( app.indexOf('async function syncOperationsDashboard()'), app.indexOf('async function loadOperationsDashboardTask(') diff --git a/control-plane/README.md b/control-plane/README.md index bf03240..1ad874b 100644 --- a/control-plane/README.md +++ b/control-plane/README.md @@ -26,7 +26,7 @@ - AgentBus 入站消息会复用 `TaskService` 的任务/会话/解析队列,并以渠道绑定员工写入 `created_by` 与不可变的 `assigned_user_id`,解析完成后通过同一 WebSocket 返回一次 `task.result`。组织级“全自动化”关闭时,手工与 AgentBus 新任务都需要人工确认;开启后,两种来源的合法解析结果都自动进入 ERP 队列,不再按来源或创建、名单、安排、修改、取消/恢复、导出等业务类型保留人工例外。历史未归属 AgentBus 任务不会自动执行。操作台在 EventSource 建连/重连、30 秒后台刷新以及页面重新可见或聚焦时重新读取数据库权威开关。缺资料、解析失败、歧义、插件校验失败或 ERP 回查不确定时仍会停止,不会绕过校验或重试不确定写入。 - 单一部署范围可以维护多个“用户渠道”。每个渠道代表一个外部 AgentBus 用户身份,并且必须一对一绑定一个有效的非管理员平台账号;一个平台账号也只能绑定一个渠道。管理员在 `/channels` 创建、绑定、停用、启用、轮换或删除渠道。只有绑定账号有效且已配置 ERP 账号的启用渠道才启动 listener;未绑定渠道失败关闭。删除会停止对应 listener、移除服务端保存的 key 和该渠道尚存的持久化回执;历史任务本体保留,其 `channel_id` 置空而 `assigned_user_id` 不变。每个渠道独立保存加密后的 AgentBus key,同一 key 不能被多个渠道复用;列表和日志都不会回显 key。`AGENTBUS_WS_URL`、重连策略和客户端类型仍是全局连接配置,`AGENTBUS_BOT_ADDRESS` 可作为渠道 bot address 的默认值。 - `/history` 同时提供可恢复的归档/恢复与显式的永久强制删除。`POST /api/tasks/:taskId/archive`、`POST /api/tasks/:taskId/restore` 和 `POST /api/tasks/bulk-archive` 保留归档语义及运行状态门禁;`DELETE /api/tasks/:taskId` 与 `POST /api/tasks/bulk-delete` 会绕过任务状态门禁并物理删除任务及其输入、事件、尝试、会话、投递和附件记录,同时清理任务 outbox,并在提交后尽力清理 OSS 对象。普通用户和组长只能操作本人任务,管理员可以处理全部授权任务;不可逆删除仍保留最小化的删除审计事件。 -- `/operations-dashboard` 是组长和管理员专用的只读业务操作看板。它支持从结果状态、操作人、上海业务日期和业务类型逐层穿透,并可在选定范围内查询姓名、完整初始/补充指令、业务结果、团号或订单号。列表和详情只回答“谁提交了什么指令、完成了什么结果”:详情返回操作人、业务类型、完整指令轮次、输入附件名称/行数和可读业务结果,不返回任务生命周期、解析/执行 JSON、技术阶段、错误码或产物地址。关键词查询先受日期、人员、业务和状态约束,单次解密匹配候选最多 2,000 条,超过时要求继续缩小范围。该路径不授予他人任务修改、ERP 执行、SSE、产物下载、账号维护或全局安全审计权限,并排除 AgentBus/system 任务。 +- `/operations-dashboard` 是组长和管理员专用的只读业务操作看板。它以不可变的任务归属账号作为员工口径,纳入同一固定组织中所有已归属的人工与 AgentBus 任务,并支持从结果状态、员工、上海业务日期和业务类型逐层穿透;历史上无法安全归属员工的 AgentBus 任务不在人员看板中被猜测归属。列表和详情只回答“谁负责什么任务、收到什么指令、完成了什么结果”:详情返回员工、业务类型、完整指令轮次、输入附件名称/行数和可读业务结果,不返回任务生命周期、解析/执行 JSON、技术阶段、错误码或产物地址。关键词查询先受日期、人员、业务和状态约束,单次解密匹配候选最多 2,000 条,超过时要求继续缩小范围。该路径不授予他人任务修改、ERP 执行、SSE、产物下载、账号维护或全局安全审计权限。 - 使用数据库渠道时设置 `AGENTBUS_ENABLED=true`;此模式不要求 `AGENTBUS_WS_TOKEN` 或 `AGENTBUS_BOT_ADDRESS`,但启用的渠道仍需要全局 `AGENTBUS_WS_URL`,并可在渠道上覆盖 bot address。保留旧环境变量配置时,服务会按需创建“默认 AgentBus 渠道”兼容旧单渠道部署;兼容渠道初始为未绑定且不启动,管理员必须在 `/channels` 绑定员工账号后再启用。`AGENTBUS_ENABLED=auto` 仅由完整的旧环境连接字段自动启用。 - `user_channels`、`tasks.channel_id` 和 `agentbus_deliveries` 共同保存入站归属、accepted 受理回执和最终 result 回执。回执以 `(channel_id, inbound_frame_id, delivery_kind)` 幂等,发送失败会重试,进程重启或 WebSocket 重连后仍会继续投递;因此不会因为超过原等待时长而丢掉最终回复。 - ERP 插件领取按任务 `assigned_user_id` 使用账户级数据库锁和 FIFO confirmed 队列:同一平台/ERP 账户在任意时刻最多一个 ERP execution,该账户的其他任务留在服务端等待;不同账户的活跃或待执行任务互不占用队列位置、可独立领取执行。管理员的组织级查看权限与执行权限完全分离:实时执行事件、插件领取、执行回执以及强制删除后的浏览器清理命令都只发送或接受任务 `assigned_user_id` 对应的登录账号,管理员不会因为能查看员工任务而收到或处理该员工的插件任务。已开始写入但结果不确定的任务只阻塞同一账户的后续领取,直到人工回查收敛或任务被明确强制删除。 diff --git a/control-plane/src/task-service.ts b/control-plane/src/task-service.ts index 3ea6917..a9dc97d 100644 --- a/control-plane/src/task-service.ts +++ b/control-plane/src/task-service.ts @@ -4736,13 +4736,14 @@ export class TaskService { const status = options.status || 'all'; const params: unknown[] = [context.organizationId, from.toISOString(), to.toISOString()]; let where = `t.organization_id = $1 - AND t.source = 'manual' + AND t.assigned_user_id IS NOT NULL + AND t.source IN ('manual', 'agentbus') AND t.created_at >= $2::timestamptz AND t.created_at < $3::timestamptz`; const actorUserId = text(options.actorUserId).trim(); if (actorUserId) { params.push(actorUserId); - where += ` AND t.created_by = $${params.length}`; + where += ` AND t.assigned_user_id = $${params.length}`; } const businessRouteId = text(options.businessRouteId).trim(); if (businessRouteId && businessRouteId !== 'unclassified' && !operationsDashboardTypeById(businessRouteId)) { @@ -4767,8 +4768,8 @@ export class TaskService { ]; 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, - creator.role AS actor_role, creator.is_active AS actor_is_active, + assignee.id AS actor_id, assignee.username AS actor_username, + assignee.role AS actor_role, assignee.is_active AS actor_is_active, to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS business_day`; const taskDetailSelect = `${taskBaseSelect}, t.original_text_ciphertext, t.message, t.success_receipt, t.success_receipt_at, @@ -4824,7 +4825,7 @@ export class TaskService { 'page_details', `SELECT ${taskDetailSelect} FROM tasks t - LEFT JOIN users creator ON creator.id = t.created_by + LEFT JOIN users assignee ON assignee.id = t.assigned_user_id WHERE t.organization_id = $1 AND t.id = ANY($2::uuid[])`, [context.organizationId, taskRowIds] @@ -4836,7 +4837,7 @@ export class TaskService { 'candidates', `SELECT ${search ? taskSearchSelect : taskCandidateSelect} FROM tasks t - LEFT JOIN users creator ON creator.id = t.created_by + LEFT JOIN users assignee ON assignee.id = t.assigned_user_id WHERE ${where} ORDER BY t.created_at DESC, t.id DESC`, params @@ -5003,8 +5004,8 @@ export class TaskService { t.created_at, t.updated_at, t.success_receipt, t.success_receipt_at, t.error_summary, t.error_summary_at, t.execution_result, t.operation, t.parse_response, - creator.id AS actor_id, creator.username AS actor_username, - creator.role AS actor_role, creator.is_active AS actor_is_active, + assignee.id AS actor_id, assignee.username AS actor_username, + assignee.role AS actor_role, assignee.is_active AS actor_is_active, to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS business_day, (SELECT COUNT(*)::int FROM agent_session_messages input_message @@ -5013,8 +5014,11 @@ export class TaskService { AND input_message.role = 'user' AND input_message.input_source IS DISTINCT FROM 'reparse') AS input_turn_count FROM tasks t - LEFT JOIN users creator ON creator.id = t.created_by - WHERE t.organization_id = $1 AND t.task_id = $2 AND t.source = 'manual'`, + LEFT JOIN users assignee ON assignee.id = t.assigned_user_id + WHERE t.organization_id = $1 + AND t.task_id = $2 + AND t.assigned_user_id IS NOT NULL + AND t.source IN ('manual', 'agentbus')`, [context.organizationId, taskId] ); if (!target.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); diff --git a/control-plane/test/account-authorization.test.ts b/control-plane/test/account-authorization.test.ts index 02dd0a0..c491c3f 100644 --- a/control-plane/test/account-authorization.test.ts +++ b/control-plane/test/account-authorization.test.ts @@ -150,14 +150,16 @@ test('administrators manage task-type grants and manual intake enforces them bef assert.match(parseResult, /taskAuthorization\.allowed && shouldAutomaticallyConfirm/); }); -test('operations dashboard is leadership-gated, business-facing, searchable, and separate from normal task authority', async () => { +test('operations dashboard is leadership-gated, cross-source, business-facing, and separate from normal task authority', async () => { const [tasks, server] = await Promise.all([ source('../src/task-service.ts'), source('../src/server.ts') ]); assert.match(tasks, /canViewOperationsDashboard[\s\S]+role === 'admin' \|\| role === 'team_lead'/); assert.match(tasks, /isTaskOwnerRestricted[\s\S]+role === 'team_lead' \|\| role === 'user'/); - assert.match(tasks, /async listOperationsDashboard[\s\S]+t\.source = 'manual'/); + assert.match(tasks, /async listOperationsDashboard[\s\S]+t\.assigned_user_id IS NOT NULL[\s\S]+t\.source IN \('manual', 'agentbus'\)/); + assert.match(tasks, /actorUserId[\s\S]+t\.assigned_user_id = \$\$\{params\.length\}/); + assert.match(tasks, /LEFT JOIN users assignee ON assignee\.id = t\.assigned_user_id/); assert.match(tasks, /operations_dashboard_range_too_large/); assert.match(tasks, /businessRouteId\?: string/); assert.match(tasks, /operations_dashboard_business_invalid/); @@ -194,7 +196,9 @@ test('operations dashboard is leadership-gated, business-facing, searchable, and tasks.indexOf('async getOperationsDashboardTask('), tasks.indexOf('async listAuditEvents(') ); - assert.match(detailMethod, /source = 'manual'/); + assert.match(detailMethod, /t\.assigned_user_id IS NOT NULL/); + assert.match(detailMethod, /t\.source IN \('manual', 'agentbus'\)/); + assert.doesNotMatch(detailMethod, /t\.source = 'manual'/); assert.match(detailMethod, /getTaskInputHistory/); assert.match(detailMethod, /instructions,[\s\S]+attachments:/); assert.doesNotMatch(detailMethod, /this\.getTask\(/);