fix: isolate ERP queues and force deletion by account
This commit is contained in:
1 parent
69ea6d2517
commit
d09b3032c0
9 files changed
+677
-97
No files matched your search
@@ -25,11 +25,11 @@
|
||||
- 微信侧的 `[WeChat attachment: 文件名]` 只是一段传输占位文字,不代表控制面已经收到文件。若同一帧没有符合契约的 `payload.attachments[]`,listener 会在进入任务服务前失败关闭、保留原名单任务的等待状态,并返回“附件内容未传到平台”;不会把占位文字创建成新业务任务。附件元数据、HTTPS URL、DNS、大小或摘要校验失败时返回对应的安全摘要,仍不回显 URL、文件字节或名单内容。当前生产部署位于受信内网,入站附件 URL 可以使用内网域名、私网 IPv4/IPv6 或 localhost;因此 AgentBus 渠道和上游桥接器必须被视为受信输入边界。
|
||||
- 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` 使用可恢复的归档/恢复,不提供物理删除。单条兼容路由 `DELETE /api/tasks/:taskId` 与批量兼容路由 `POST /api/tasks/bulk-delete` 也只执行归档;普通用户和组长只能归档/恢复本人任务,管理员可以处理全部授权任务。任务、输入、事件、尝试、附件元数据与审计记录继续保留,物理清除必须等待单独批准的保留期限和不可逆清除设计。
|
||||
- `/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 任务。
|
||||
- 使用数据库渠道时设置 `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 插件领取由组织级数据库锁和 FIFO confirmed 队列统一串行化:同一组织/同一 ERP 浏览器会话在任意时刻最多一个 ERP execution,其他任务留在服务端等待;已开始写入但结果不确定的任务会阻塞后续领取,直到人工回查收敛。
|
||||
- ERP 插件领取按任务 `assigned_user_id` 使用账户级数据库锁和 FIFO confirmed 队列:同一平台/ERP 账户在任意时刻最多一个 ERP execution,该账户的其他任务留在服务端等待;不同账户的活跃或待执行任务互不占用队列位置、可独立领取执行。管理员的组织级查看权限与执行权限完全分离:实时执行事件、插件领取、执行回执以及强制删除后的浏览器清理命令都只发送或接受任务 `assigned_user_id` 对应的登录账号,管理员不会因为能查看员工任务而收到或处理该员工的插件任务。已开始写入但结果不确定的任务只阻塞同一账户的后续领取,直到人工回查收敛或任务被明确强制删除。
|
||||
|
||||
## 任务级会话续接
|
||||
|
||||
|
||||
+37
-14
@@ -21,10 +21,10 @@ import {
|
||||
import {
|
||||
TaskError,
|
||||
TaskService,
|
||||
canAccessTask,
|
||||
canViewOperationsDashboard,
|
||||
type ParseDecisionInput,
|
||||
type ParseTaskClaim,
|
||||
type TaskBrowserCommand,
|
||||
type TaskContext,
|
||||
type TaskEvent
|
||||
} from './task-service.js';
|
||||
@@ -168,8 +168,19 @@ const listTasksQuerySchema = z.object({
|
||||
z.boolean()
|
||||
).default(true)
|
||||
});
|
||||
const taskEventsQuerySchema = z.object({
|
||||
since: z.coerce.number().int().min(0).default(0),
|
||||
executable_by: z.literal('me').default('me')
|
||||
});
|
||||
const taskIdListSchema = z.array(z.string().trim().min(1).max(200)).min(1).max(100);
|
||||
const taskBulkDeleteSchema = z.object({
|
||||
task_ids: z.array(z.string().trim().min(1).max(200)).min(1).max(100),
|
||||
task_ids: taskIdListSchema
|
||||
}).refine(
|
||||
(body) => new Set(body.task_ids).size === body.task_ids.length,
|
||||
{ message: '任务编号不能重复。', path: ['task_ids'] }
|
||||
);
|
||||
const taskBulkArchiveSchema = z.object({
|
||||
task_ids: taskIdListSchema,
|
||||
reason: z.string().trim().max(500).optional()
|
||||
}).refine(
|
||||
(body) => new Set(body.task_ids).size === body.task_ids.length,
|
||||
@@ -1302,14 +1313,14 @@ export async function buildServer({
|
||||
const body = taskBulkDeleteSchema.parse(request.body);
|
||||
return {
|
||||
ok: true,
|
||||
archived: true,
|
||||
...(await tasks.archiveTasks(contextFor(session, request), body.task_ids, body.reason))
|
||||
deleted: true,
|
||||
...(await tasks.hardDeleteTasks(contextFor(session, request), body.task_ids))
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/tasks/bulk-archive', async (request) => {
|
||||
const session = await requireMutationSession(request);
|
||||
const body = taskBulkDeleteSchema.parse(request.body);
|
||||
const body = taskBulkArchiveSchema.parse(request.body);
|
||||
return {
|
||||
ok: true,
|
||||
archived: true,
|
||||
@@ -1320,11 +1331,9 @@ export async function buildServer({
|
||||
app.delete('/api/tasks/:taskId', async (request) => {
|
||||
const session = await requireMutationSession(request);
|
||||
const params = request.params as { taskId: string };
|
||||
const body = taskArchiveSchema.parse(request.body || {});
|
||||
return {
|
||||
ok: true,
|
||||
archived: true,
|
||||
task: await tasks.archiveTask(contextFor(session, request), params.taskId, body.reason)
|
||||
...(await tasks.hardDeleteTask(contextFor(session, request), params.taskId))
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1422,8 +1431,8 @@ export async function buildServer({
|
||||
|
||||
app.get('/api/events', async (request, reply) => {
|
||||
const session = await getSession(request);
|
||||
const query = (request.query || {}) as Record<string, unknown>;
|
||||
const querySince = Number(query.since || 0);
|
||||
const query = taskEventsQuerySchema.parse(request.query || {});
|
||||
const querySince = query.since;
|
||||
const reconnectSince = Number(request.headers['last-event-id'] || 0);
|
||||
const since = Math.max(
|
||||
Number.isFinite(querySince) ? querySince : 0,
|
||||
@@ -1439,9 +1448,10 @@ export async function buildServer({
|
||||
});
|
||||
const send = (event: TaskEvent) => {
|
||||
if (event.organization_id !== session.user.organizationId) return;
|
||||
if (!canAccessTask(contextFor(session, request), {
|
||||
assignedUserId: event.owner_user_id
|
||||
})) return;
|
||||
// This is the executable wake-up feed, not the administrator's read
|
||||
// model. Every role, including admin, receives only its own assigned
|
||||
// task events so visibility can never turn into plugin dispatch.
|
||||
if (event.owner_user_id !== session.user.id) return;
|
||||
const publicEvent = {
|
||||
id: event.id,
|
||||
organization_id: event.organization_id,
|
||||
@@ -1454,13 +1464,26 @@ export async function buildServer({
|
||||
};
|
||||
response.write(`id: ${event.id}\nevent: task\ndata: ${JSON.stringify(publicEvent)}\n\n`);
|
||||
};
|
||||
for (const event of await tasks.eventsSince(session.user.organizationId, since, contextFor(session, request))) send(event);
|
||||
const sendBrowserCommand = (command: TaskBrowserCommand) => {
|
||||
if (command.organization_id !== session.user.organizationId) return;
|
||||
if (command.assigned_user_id !== session.user.id) return;
|
||||
response.write(`event: browser-command\ndata: ${JSON.stringify({
|
||||
action: command.action,
|
||||
task_id: command.task_id,
|
||||
target_user_id: command.assigned_user_id,
|
||||
created_at: command.created_at
|
||||
})}\n\n`);
|
||||
};
|
||||
for (const event of await tasks.eventsSince(session.user.organizationId, session.user.id, since)) send(event);
|
||||
const heartbeat = setInterval(() => response.write(': heartbeat\n\n'), 20_000);
|
||||
const onTask = (event: TaskEvent) => send(event);
|
||||
const onBrowserCommand = (command: TaskBrowserCommand) => sendBrowserCommand(command);
|
||||
tasks.events.on('task', onTask);
|
||||
tasks.events.on('browser-command', onBrowserCommand);
|
||||
request.raw.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
tasks.events.off('task', onTask);
|
||||
tasks.events.off('browser-command', onBrowserCommand);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,14 @@ export interface TaskEvent {
|
||||
task_source?: TaskSource;
|
||||
}
|
||||
|
||||
export interface TaskBrowserCommand {
|
||||
organization_id: string;
|
||||
task_id: string;
|
||||
assigned_user_id: string;
|
||||
action: 'hard_delete';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PublicTaskEvent {
|
||||
id: number;
|
||||
status: string;
|
||||
@@ -2925,6 +2933,17 @@ export class TaskService {
|
||||
this.events.emit('task', event);
|
||||
}
|
||||
|
||||
private notifyBrowserCommand(command: TaskBrowserCommand): void {
|
||||
this.log('info', {
|
||||
diagnostic_event: 'task.browser_command.emitted',
|
||||
diagnostic_stage: 'browser_routing',
|
||||
task_id: command.task_id,
|
||||
command_action: command.action,
|
||||
assigned_user_id: command.assigned_user_id
|
||||
}, 'task browser command emitted to assigned account');
|
||||
this.events.emit('browser-command', command);
|
||||
}
|
||||
|
||||
private async audit(
|
||||
client: import('pg').PoolClient,
|
||||
context: TaskContext,
|
||||
@@ -6173,12 +6192,23 @@ export class TaskService {
|
||||
const leaseOwner = `browser:${connectionId}`;
|
||||
const outcome = await withTransaction(this.config, async (client) => {
|
||||
const organization = await client.query(
|
||||
`SELECT id FROM organizations WHERE id = $1 FOR UPDATE`,
|
||||
`SELECT id FROM organizations WHERE id = $1`,
|
||||
[context.organizationId]
|
||||
);
|
||||
if (!organization.rowCount) throw new TaskError('organization_not_found', '组织不存在。', 404);
|
||||
if (!context.userId) {
|
||||
throw new TaskError('task_execution_assignee_mismatch', '任务不属于当前账号的云电脑,禁止领取执行。', 403);
|
||||
}
|
||||
// Serialize claims only for this execution account. Other accounts use
|
||||
// different advisory locks and may claim their own FIFO concurrently.
|
||||
await client.query(
|
||||
`SELECT pg_advisory_xact_lock(
|
||||
hashtextextended($1::text || ':erp-account-queue:' || $2::text, 0)
|
||||
)`,
|
||||
[context.organizationId, context.userId]
|
||||
);
|
||||
const row = await this.lockTaskForAccess(client, context, taskId);
|
||||
if (!context.userId || text(row.assigned_user_id) !== context.userId) {
|
||||
if (text(row.assigned_user_id) !== context.userId) {
|
||||
throw new TaskError('task_execution_assignee_mismatch', '任务不属于当前账号的云电脑,禁止领取执行。', 403);
|
||||
}
|
||||
await this.assertTaskCreatorBusinessAuthorizationInTransaction(client, row, context.requestId);
|
||||
@@ -6226,12 +6256,13 @@ export class TaskService {
|
||||
FROM tasks t
|
||||
JOIN task_attempts a ON a.task_id = t.id AND a.phase = 'erp'
|
||||
WHERE t.organization_id = $1
|
||||
AND t.assigned_user_id = $2
|
||||
AND a.status IN ('accepted', 'running')
|
||||
AND t.lease_expires_at IS NOT NULL
|
||||
AND t.lease_expires_at > now()
|
||||
ORDER BY t.created_at ASC, t.id ASC
|
||||
FOR UPDATE OF t, a`,
|
||||
[context.organizationId]
|
||||
[context.organizationId, context.userId]
|
||||
);
|
||||
const activeOther = (activeExecutions.rows as Record<string, unknown>[])
|
||||
.find((active) => text(active.id) !== text(row.id));
|
||||
@@ -6239,6 +6270,7 @@ export class TaskService {
|
||||
`SELECT id, task_id
|
||||
FROM tasks
|
||||
WHERE organization_id = $1
|
||||
AND assigned_user_id = $2
|
||||
AND status = 'confirmed'
|
||||
AND handoff_status = 'awaiting_handoff'
|
||||
AND (
|
||||
@@ -6265,7 +6297,7 @@ export class TaskService {
|
||||
)
|
||||
ORDER BY created_at ASC, id ASC
|
||||
FOR UPDATE`,
|
||||
[context.organizationId]
|
||||
[context.organizationId, context.userId]
|
||||
);
|
||||
const queueRows = queue.rows as Record<string, unknown>[];
|
||||
const queueIndex = queueRows.findIndex((candidate) => text(candidate.id) === text(row.id));
|
||||
@@ -7067,6 +7099,127 @@ export class TaskService {
|
||||
return this.getTask(context.organizationId, taskId, context);
|
||||
}
|
||||
|
||||
async hardDeleteTask(
|
||||
context: TaskContext,
|
||||
taskId: string
|
||||
): Promise<{ task_id: string; deleted: boolean }> {
|
||||
const outcome = await this.hardDeleteTasks(context, [taskId]);
|
||||
return { task_id: taskId, deleted: outcome.deleted_count === 1 };
|
||||
}
|
||||
|
||||
async hardDeleteTasks(
|
||||
context: TaskContext,
|
||||
taskIds: string[]
|
||||
): Promise<{ task_ids: string[]; deleted_count: number }> {
|
||||
const normalizedTaskIds = [...new Set(taskIds.map((taskId) => text(taskId).trim()).filter(Boolean))];
|
||||
if (!normalizedTaskIds.length) throw new TaskError('invalid_task_ids', '请至少选择一个待删除任务。', 400);
|
||||
const outcome = await withTransaction(this.config, async (client) => {
|
||||
const lookup = await client.query(
|
||||
`SELECT * FROM tasks
|
||||
WHERE organization_id = $1
|
||||
AND task_id = ANY($2::text[])
|
||||
AND ($3::boolean = false OR assigned_user_id = $4)
|
||||
ORDER BY task_id
|
||||
FOR UPDATE`,
|
||||
[context.organizationId, normalizedTaskIds, this.isOwnerRestrictedUser(context), context.userId || null]
|
||||
);
|
||||
const rows = lookup.rows as Record<string, unknown>[];
|
||||
const foundTaskIds = new Set(rows.map((row) => text(row.task_id)));
|
||||
const missingTaskIds = normalizedTaskIds.filter((taskId) => !foundTaskIds.has(taskId));
|
||||
if (missingTaskIds.length) throw new TaskError('task_not_found', '任务不存在。', 404);
|
||||
|
||||
// Hard delete intentionally has no status or handoff-state gate. The row
|
||||
// locks settle concurrent transitions; existing task foreign keys then
|
||||
// remove every task-owned record through ON DELETE CASCADE.
|
||||
|
||||
const internalTaskIds = rows.map((row) => text(row.id));
|
||||
const artifactLookup = await client.query(
|
||||
`SELECT artifact.id, artifact.organization_id, task.task_id AS public_task_id,
|
||||
artifact.execution_id, artifact.artifact_index, artifact.artifact_type,
|
||||
artifact.file_name, artifact.content_type, artifact.byte_size, artifact.sha256,
|
||||
artifact.storage_backend, artifact.storage_key, artifact.created_at
|
||||
FROM task_artifacts artifact
|
||||
JOIN tasks task ON task.id = artifact.task_id
|
||||
WHERE artifact.task_id = ANY($1::uuid[])
|
||||
ORDER BY artifact.created_at, artifact.id`,
|
||||
[internalTaskIds]
|
||||
);
|
||||
const artifacts: StoredTaskArtifact[] = (artifactLookup.rows as Record<string, unknown>[]).map((row) => ({
|
||||
id: text(row.id),
|
||||
organization_id: text(row.organization_id),
|
||||
task_id: text(row.public_task_id),
|
||||
execution_id: text(row.execution_id),
|
||||
artifact_index: Number(row.artifact_index),
|
||||
type: text(row.artifact_type),
|
||||
file_name: text(row.file_name),
|
||||
content_type: text(row.content_type),
|
||||
byte_size: Number(row.byte_size),
|
||||
sha256: text(row.sha256),
|
||||
storage_backend: text(row.storage_backend),
|
||||
storage_key: text(row.storage_key) || null,
|
||||
public_url: null,
|
||||
created_at: new Date(String(row.created_at)).toISOString()
|
||||
}));
|
||||
|
||||
// Retain only a minimal audit marker for the destructive action itself;
|
||||
// it has no foreign key to the task and contains no task input/output.
|
||||
for (const row of rows) {
|
||||
await this.audit(client, context, 'task.hard_deleted', text(row.task_id), {
|
||||
forced: true,
|
||||
previous_status: text(row.status),
|
||||
previous_handoff_status: text(row.handoff_status) || null,
|
||||
was_archived: Boolean(row.archived_at)
|
||||
});
|
||||
}
|
||||
await client.query(
|
||||
`DELETE FROM outbox_events
|
||||
WHERE organization_id = $1
|
||||
AND aggregate_type = 'task'
|
||||
AND aggregate_id = ANY($2::text[])`,
|
||||
[context.organizationId, normalizedTaskIds]
|
||||
);
|
||||
const deleted = await client.query(
|
||||
`DELETE FROM tasks
|
||||
WHERE id = ANY($1::uuid[])
|
||||
RETURNING task_id`,
|
||||
[internalTaskIds]
|
||||
);
|
||||
const deletedCount = Number(deleted.rowCount || 0);
|
||||
if (deletedCount !== rows.length) {
|
||||
throw new TaskError('task_delete_incomplete', '任务未能完整删除,请重试。', 500);
|
||||
}
|
||||
const browserCommands: TaskBrowserCommand[] = rows.flatMap((row) => {
|
||||
const assignedUserId = text(row.assigned_user_id);
|
||||
if (!assignedUserId) return [];
|
||||
return [{
|
||||
organization_id: context.organizationId,
|
||||
task_id: text(row.task_id),
|
||||
assigned_user_id: assignedUserId,
|
||||
action: 'hard_delete' as const,
|
||||
created_at: new Date().toISOString()
|
||||
}];
|
||||
});
|
||||
return { artifacts, browserCommands, deletedCount };
|
||||
});
|
||||
|
||||
for (const command of outcome.browserCommands) this.notifyBrowserCommand(command);
|
||||
if (outcome.artifacts.length && this.artifactStore.cleanup) {
|
||||
try {
|
||||
await this.artifactStore.cleanup(outcome.artifacts);
|
||||
} catch (error) {
|
||||
this.log('error', {
|
||||
diagnostic_event: 'task.hard_delete_artifact_cleanup_failed',
|
||||
diagnostic_stage: 'artifact_cleanup',
|
||||
request_id: context.requestId,
|
||||
task_count: normalizedTaskIds.length,
|
||||
artifact_count: outcome.artifacts.length,
|
||||
error_type: error instanceof Error ? error.name : 'unknown_error'
|
||||
}, 'hard-deleted task artifact cleanup failed');
|
||||
}
|
||||
}
|
||||
return { task_ids: normalizedTaskIds, deleted_count: outcome.deletedCount };
|
||||
}
|
||||
|
||||
async cancelTask(context: TaskContext, taskId: string): Promise<PublicTask> {
|
||||
const outcome = await withTransaction(this.config, async (client) => {
|
||||
const row = await this.lockTaskForAccess(client, context, taskId);
|
||||
@@ -7213,15 +7366,15 @@ export class TaskService {
|
||||
});
|
||||
}
|
||||
|
||||
async eventsSince(organizationId: string, since = 0, access?: TaskAccessScope): Promise<TaskEvent[]> {
|
||||
async eventsSince(organizationId: string, assignedUserId: string, since = 0): Promise<TaskEvent[]> {
|
||||
const result = await getPool(this.config).query(
|
||||
`SELECT e.id, e.organization_id, t.task_id, t.assigned_user_id, t.source,
|
||||
e.status, e.stage, e.message, e.payload, e.created_at
|
||||
FROM task_events e JOIN tasks t ON t.id = e.task_id
|
||||
WHERE e.organization_id = $1 AND e.id > $2
|
||||
AND ($3::boolean = false OR t.assigned_user_id = $4)
|
||||
AND t.assigned_user_id = $3
|
||||
ORDER BY e.id ASC LIMIT 500`,
|
||||
[organizationId, since, isTaskOwnerRestricted(access?.role), access?.userId || null]
|
||||
[organizationId, since, assignedUserId]
|
||||
);
|
||||
return result.rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
|
||||
@@ -221,7 +221,7 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event
|
||||
assert.match(body, /lockTaskForAccess\(/, `${mutation} uses the task access lock`);
|
||||
}
|
||||
assert.match(tasks, /async getTaskArtifact[\s\S]+assigned_user_id = \$4/);
|
||||
assert.match(tasks, /async eventsSince[\s\S]+t\.assigned_user_id = \$4/);
|
||||
assert.match(tasks, /async eventsSince[\s\S]+t\.assigned_user_id = \$3/);
|
||||
assert.match(tasks, /async getTaskInputHistory[\s\S]+actor_user_id/);
|
||||
assert.match(tasks, /connection\.organization_id = \$1[\s\S]+connection\.user_id = \$2[\s\S]+connection\.connection_id = \$3/);
|
||||
assert.match(tasks, /WHERE browser_connections\.user_id = EXCLUDED\.user_id/);
|
||||
@@ -238,10 +238,92 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event
|
||||
assert.match(tasks, /pg_advisory_xact_lock/);
|
||||
assert.match(server, /tasks\.listTasksPage[\s\S]+access: contextFor\(session, request\)/);
|
||||
assert.match(server, /tasks\.getTaskArtifact[\s\S]+contextFor\(session, request\)/);
|
||||
assert.match(server, /tasks\.eventsSince\(session\.user\.organizationId, since, contextFor\(session, request\)\)/);
|
||||
assert.match(server, /tasks\.eventsSince\(session\.user\.organizationId, session\.user\.id, since\)/);
|
||||
});
|
||||
|
||||
test('operator UI exposes role-aware accounts, executive drill-through, original input, final output, and reversible archive', async () => {
|
||||
test('administrator visibility is isolated from executable events and plugin result routing', async () => {
|
||||
const [tasks, server, app] = await Promise.all([
|
||||
source('../src/task-service.ts'),
|
||||
source('../src/server.ts'),
|
||||
source('../../LianSyn-platform/app.js')
|
||||
]);
|
||||
const eventHistory = tasks.slice(tasks.indexOf('async eventsSince('));
|
||||
assert.match(eventHistory, /assignedUserId: string/);
|
||||
assert.match(eventHistory, /AND t\.assigned_user_id = \$3/);
|
||||
assert.doesNotMatch(eventHistory, /isTaskOwnerRestricted\(access\?\.role\)/);
|
||||
|
||||
const eventRoute = server.slice(
|
||||
server.indexOf("app.get('/api/events'"),
|
||||
server.indexOf('app.setErrorHandler')
|
||||
);
|
||||
assert.match(eventRoute, /event\.owner_user_id !== session\.user\.id/);
|
||||
assert.match(eventRoute, /command\.assigned_user_id !== session\.user\.id/);
|
||||
assert.match(eventRoute, /event: browser-command/);
|
||||
assert.match(eventRoute, /tasks\.eventsSince\(session\.user\.organizationId, session\.user\.id, since\)/);
|
||||
|
||||
const eventStream = app.slice(
|
||||
app.indexOf('function startRemoteEventStream()'),
|
||||
app.indexOf('function cacheRuntimeTask(')
|
||||
);
|
||||
assert.match(eventStream, /filter\(taskAssignedToCurrentAccount\)/);
|
||||
assert.match(eventStream, /executable_by=me/);
|
||||
assert.match(eventStream, /addEventListener\('browser-command'/);
|
||||
|
||||
const bridgeListener = app.slice(
|
||||
app.indexOf("window.addEventListener('message'"),
|
||||
app.indexOf('async function parseRawInstruction')
|
||||
);
|
||||
assert.match(bridgeListener, /TASK_RESULT_CHANGED/);
|
||||
assert.match(bridgeListener, /extensionTaskBelongsToCurrentAccount\(taskId\)/);
|
||||
const resultQueue = app.slice(
|
||||
app.indexOf('async function persistExtensionTaskResult('),
|
||||
app.indexOf('async function reconcileTaskReceipt(')
|
||||
);
|
||||
assert.match(resultQueue, /if \(!extensionTaskBelongsToCurrentAccount\(taskId\)\) return false/);
|
||||
assert.match(resultQueue, /!extensionTaskBelongsToCurrentAccount\(normalizedTaskId\)/);
|
||||
assert.match(resultQueue, /!knownTask \|\| !taskAssignedToCurrentAccount\(knownTask\)/);
|
||||
});
|
||||
|
||||
test('ERP browser claims serialize only the assigned account queue', async () => {
|
||||
const tasks = await source('../src/task-service.ts');
|
||||
const claim = tasks.slice(tasks.indexOf('async claimForBrowser('), tasks.indexOf('async recordExecutionResult('));
|
||||
assert.match(claim, /pg_advisory_xact_lock\([\s\S]+erp-account-queue:[\s\S]+context\.organizationId, context\.userId/);
|
||||
assert.doesNotMatch(claim, /SELECT id FROM organizations WHERE id = \$1 FOR UPDATE/);
|
||||
assert.match(claim, /WHERE t\.organization_id = \$1\s+AND t\.assigned_user_id = \$2[\s\S]+a\.status IN \('accepted', 'running'\)/);
|
||||
assert.match(claim, /WHERE organization_id = \$1\s+AND assigned_user_id = \$2\s+AND status = 'confirmed'/);
|
||||
assert.equal((claim.match(/assigned_user_id = \$2/g) || []).length, 2);
|
||||
assert.match(claim, /\[context\.organizationId, context\.userId\]/);
|
||||
});
|
||||
|
||||
test('force delete physically removes accessible tasks without the archive state gate', async () => {
|
||||
const [tasks, server] = await Promise.all([
|
||||
source('../src/task-service.ts'),
|
||||
source('../src/server.ts')
|
||||
]);
|
||||
const hardDelete = tasks.slice(tasks.indexOf('async hardDeleteTask('), tasks.indexOf('async cancelTask('));
|
||||
assert.match(hardDelete, /async hardDeleteTasks\(/);
|
||||
assert.match(hardDelete, /AND \(\$3::boolean = false OR assigned_user_id = \$4\)/);
|
||||
assert.match(hardDelete, /DELETE FROM outbox_events[\s\S]+aggregate_id = ANY\(\$2::text\[\]\)/);
|
||||
assert.match(hardDelete, /DELETE FROM tasks[\s\S]+WHERE id = ANY\(\$1::uuid\[\]\)/);
|
||||
assert.match(hardDelete, /task\.hard_deleted/);
|
||||
assert.match(hardDelete, /assigned_user_id: assignedUserId/);
|
||||
assert.match(hardDelete, /this\.notifyBrowserCommand\(command\)/);
|
||||
assert.match(hardDelete, /this\.artifactStore\.cleanup\(outcome\.artifacts\)/);
|
||||
assert.doesNotMatch(hardDelete, /task_archive_blocked|正在处理或等待 ERP 执行,不能归档/);
|
||||
const bulkDeleteRoute = server.slice(
|
||||
server.indexOf("app.post('/api/tasks/bulk-delete'"),
|
||||
server.indexOf("app.post('/api/tasks/bulk-archive'")
|
||||
);
|
||||
const singleDeleteRoute = server.slice(
|
||||
server.indexOf("app.delete('/api/tasks/:taskId'"),
|
||||
server.indexOf("app.post('/api/tasks/:taskId/archive'")
|
||||
);
|
||||
assert.match(bulkDeleteRoute, /tasks\.hardDeleteTasks/);
|
||||
assert.match(singleDeleteRoute, /tasks\.hardDeleteTask/);
|
||||
assert.doesNotMatch(`${bulkDeleteRoute}\n${singleDeleteRoute}`, /tasks\.archiveTask|tasks\.archiveTasks/);
|
||||
});
|
||||
|
||||
test('operator UI exposes role-aware accounts, executive drill-through, archive, and explicit permanent deletion', async () => {
|
||||
const [app, index, retention] = await Promise.all([
|
||||
source('../../LianSyn-platform/app.js'),
|
||||
source('../../LianSyn-platform/index.html'),
|
||||
@@ -273,6 +355,10 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
|
||||
assert.match(index, /id="operationsDashboardDetail"/);
|
||||
assert.doesNotMatch(index, /平台运行全景|operations-dashboard-hero|OPERATIONS OVERVIEW|BUSINESS TRACE|指令操作历史/);
|
||||
assert.match(index, /id="historyArchiveInput"/);
|
||||
assert.match(index, /id="historyArchiveSelectedButton"/);
|
||||
assert.match(index, /id="historyDeleteSelectedButton"[^>]*>强制删除所选</);
|
||||
assert.match(index, /id="archiveTaskButton"/);
|
||||
assert.match(index, /id="deleteTaskButton"[^>]*>强制删除任务</);
|
||||
assert.match(app, /authUser\?\.role === 'admin'/);
|
||||
assert.doesNotMatch(app, /passwordChangeForced|must_change_password/);
|
||||
assert.match(app, /crypto\.randomUUID/);
|
||||
@@ -334,7 +420,11 @@ test('operator UI exposes role-aware accounts, executive drill-through, original
|
||||
assert.doesNotMatch(dashboardDetailRenderer, /任务生命周期|处理结果与技术上下文|renderTaskLifecycle|JSON\.stringify|task\.stage|parse_response|operation:/);
|
||||
assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/);
|
||||
assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/);
|
||||
assert.doesNotMatch(app, /sendToExtension\('DELETE_TASK'/);
|
||||
assert.match(app, /method: 'DELETE'/);
|
||||
assert.match(app, /sendToExtension\('DELETE_TASK'/);
|
||||
assert.match(app, /selectedTasks\.filter\(taskAssignedToCurrentAccount\)/);
|
||||
assert.match(app, /command\?\.target_user_id !== authUser\?\.id/);
|
||||
assert.match(app, /此操作不受“正在处理”或“等待 ERP 执行”状态限制/);
|
||||
assert.match(retention, /SET archived_at = now\(\)/);
|
||||
assert.doesNotMatch(retention, /DELETE FROM tasks/);
|
||||
assert.doesNotMatch(retention, /DELETE FROM audit_events/);
|
||||
@@ -363,6 +453,6 @@ test('account authorization editor uses a scroll-safe open layout without overri
|
||||
assert.match(openLayoutSource, /overflow:\s*visible/);
|
||||
assert.doesNotMatch(styles, /\.account-panel\s*\{\s*grid-template-rows:/);
|
||||
|
||||
assert.match(index, /styles\.css\?v=20260902-account-authorization-layout-1/);
|
||||
assert.match(index, /app\.js\?v=20260902-account-authorization-layout-1/);
|
||||
assert.match(index, /styles\.css\?v=20260902-account-routing-hard-delete-2/);
|
||||
assert.match(index, /app\.js\?v=20260902-account-routing-hard-delete-2/);
|
||||
});
|
||||
@@ -1231,8 +1231,8 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8');
|
||||
assert.match(index, /id="loginPanel"/);
|
||||
assert.match(index, /id="workbench"[^>]*hidden/);
|
||||
assert.match(index, /styles\.css\?v=20260902-account-authorization-layout-1/);
|
||||
assert.match(index, /app\.js\?v=20260902-account-authorization-layout-1/);
|
||||
assert.match(index, /styles\.css\?v=20260902-account-routing-hard-delete-2/);
|
||||
assert.match(index, /app\.js\?v=20260902-account-routing-hard-delete-2/);
|
||||
assert.match(index, /id="statusDetailsPopover"/);
|
||||
assert.match(index, /id="statusDetailsRefresh"/);
|
||||
assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/);
|
||||
@@ -1242,6 +1242,7 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
assert.match(app, /historyPagination/);
|
||||
assert.match(index, /id="historyBatchActions"/);
|
||||
assert.match(index, /id="historySelectAll"/);
|
||||
assert.match(index, /id="historyArchiveSelectedButton"/);
|
||||
assert.match(index, /id="historyDeleteSelectedButton"/);
|
||||
assert.match(app, /IS_HISTORY_PAGE = CURRENT_PAGE === '\/history'/);
|
||||
assert.match(index, /href="\/history"/);
|
||||
@@ -1392,28 +1393,39 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
assert.match(app, /operation_contract_validation/);
|
||||
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/);
|
||||
assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/);
|
||||
assert.match(app, /const taskArchiveStates = new Map\(\)/);
|
||||
assert.match(app, /const taskDeleteStates = new Map\(\)/);
|
||||
assert.match(app, /taskDeleteStates\.get\(task\.task_id\)/);
|
||||
assert.match(app, /taskDeleteStates\.set\(taskId, 'deleting'\)/);
|
||||
assert.match(app, /deleteButton\.textContent = task\?\.archived_at \? '恢复中…' : '归档中…'/);
|
||||
assert.doesNotMatch(app, /sendToExtension\('DELETE_TASK'/);
|
||||
assert.match(app, /deleteButton\.textContent = '强制删除中…'/);
|
||||
assert.match(app, /sendToExtension\('DELETE_TASK'/);
|
||||
assert.match(app, /method: 'DELETE'/);
|
||||
assert.match(app, /此操作不受“正在处理”或“等待 ERP 执行”状态限制/);
|
||||
assert.match(taskService, /async archiveTask\(/);
|
||||
assert.match(taskService, /async archiveTasks\(/);
|
||||
assert.match(taskService, /async restoreTask\(/);
|
||||
assert.match(taskService, /async hardDeleteTask\(/);
|
||||
assert.match(taskService, /async hardDeleteTasks\(/);
|
||||
assert.match(taskService, /archived_at = now\(\), archived_by = \$1/);
|
||||
assert.match(taskService, /SET archived_at = NULL, archived_by = NULL, archive_reason = NULL/);
|
||||
assert.match(taskService, /const missingTaskIds = normalizedTaskIds\.filter/);
|
||||
assert.doesNotMatch(taskService, /async hardDeleteTask\(/);
|
||||
assert.match(taskService, /task\.hard_deleted/);
|
||||
assert.match(taskService, /this\.artifactStore\.cleanup\(outcome\.artifacts\)/);
|
||||
assert.doesNotMatch(taskService, /DELETE FROM audit_events/);
|
||||
assert.doesNotMatch(taskService, /DELETE FROM tasks/);
|
||||
assert.match(taskService, /DELETE FROM tasks/);
|
||||
assert.match(taskService, /erp-account-queue/);
|
||||
assert.match(taskService, /t\.assigned_user_id = \$2/);
|
||||
assert.match(taskService, /AND assigned_user_id = \$2\s+AND status = 'confirmed'/);
|
||||
assert.match(server, /taskBulkDeleteSchema/);
|
||||
assert.match(server, /taskBulkArchiveSchema/);
|
||||
assert.match(server, /\.max\(100\)/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/bulk-delete'/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/bulk-archive'/);
|
||||
assert.match(server, /app\.delete\('\/api\/tasks\/:taskId'/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/archive'/);
|
||||
assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/restore'/);
|
||||
assert.doesNotMatch(server, /tasks\.hardDelete/);
|
||||
assert.match(server, /tasks\.hardDeleteTasks/);
|
||||
assert.match(server, /tasks\.hardDeleteTask/);
|
||||
assert.match(bridge, /status: 'deleted'/);
|
||||
assert.match(background, /LTJT_HARD_DELETE_TASK/);
|
||||
assert.doesNotMatch(app, /task-json-output|taskResponseJson/);
|
||||
@@ -1428,6 +1440,7 @@ test('operator page has a login gate and uses the durable task API', async () =>
|
||||
assert.match(styles, /\.task-stage-card \{[\s\S]*display: flex;[\s\S]*justify-content: space-between;/);
|
||||
assert.match(styles, /\.workbench-grid \{[\s\S]*min-width: 0;[\s\S]*overflow: hidden;/);
|
||||
assert.match(styles, /overflow-x: hidden/);
|
||||
assert.match(styles, /\.task-card-actions/);
|
||||
assert.match(styles, /\.task-card-delete/);
|
||||
assert.match(styles, /\.history-batch-actions/);
|
||||
assert.match(app, /currentOption = stage\.options\.find/);
|
||||
|
||||
Reference in new issue
Block a user