fix: isolate ERP queues and force deletion by account

This commit is contained in:
inman committed 2026-09-03 09:45:44 +08:00
1 parent 69ea6d2517
commit d09b3032c0
9 files changed
+677 -97

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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);
});
});
+160 -7
View File
@@ -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/);
});
+20 -7
View File
@@ -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/);