diff --git a/.project-docs/30-worklog/tasks/20260902-agentbus-account-routing-b62f19e4.md b/.project-docs/30-worklog/tasks/20260902-agentbus-account-routing-b62f19e4.md new file mode 100644 index 0000000..45b7bc0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-agentbus-account-routing-b62f19e4.md @@ -0,0 +1,65 @@ +# Task: Bind AgentBus channels to account workers + +## Identity + +- Task ID: 20260902-agentbus-account-routing-b62f19e4 +- Mode: Feature +- Branch: codex/20260902-agentbus-account-routing-b62f19e4-agentbus-account-routing-b62f19e4 +- Worktree: /Users/inmanx/Documents/lwltAPI-agentbus-account-routing-b62f19e4 +- Base commit: d034f649c4e7c5d0856f22053b92d2e5a63be5eb +- Owner: codex +- Status: Ready for integration + +## Scope + +- Add a one-to-one AgentBus channel owner on platform employee accounts and stamp every new task with an immutable execution assignee. +- Route inbound AgentBus messages through the bound employee's identity and existing business-route allowlist. +- Keep administrators able to manage and inspect channels/tasks while preventing them from claiming another account's ERP work. +- Make one fresh browser connection the only execution worker for each account and fail closed when the expected ERP account is missing or does not match. +- Extend account/channel administration UI, browser heartbeat, extension ERP-session inspection, migrations, contracts, tests, and the versioned extension release. + +## Intent And Constraints + +- User-authorized routing model: each employee platform account binds one AgentBus channel and one expected ERP account; administrator accounts remain unbound from employee channels. +- Preserve organization-wide ERP FIFO serialization; this task does not authorize parallel ERP writes. +- Existing unbound AgentBus channels and historical unassigned AgentBus tasks must not execute automatically. +- Browser worker failover is automatic only after the previous worker heartbeat becomes stale; concurrent cloud PCs must not race or alternate ownership. +- No production database migration, deployment, service restart, or real ERP write is authorized in this task. +- Preserve unrelated dashboard-filter work in the main worktree; integration must reconcile the known overlap in `control-plane/src/task-service.ts` and `LianSyn-platform/app.js`. + +## Outcome + +- Added migration `018_agentbus_account_workers`: non-admin accounts can carry a case-insensitively unique expected ERP account; each AgentBus channel has one scoped employee owner; every new task stores its execution assignee; existing manual tasks are backfilled while historical AgentBus tasks remain deliberately unassigned; one connected browser worker is allowed per account. +- AgentBus channel creation/update now requires an active `user` or `team_lead` with an ERP account. One account cannot own multiple channels, one AgentBus key cannot fan out across channels, enabled unbound legacy channels stay stopped, and listener intake rechecks that its runtime owner still matches the database owner. +- AgentBus intake executes under the bound employee identity and business-route allowlist. The assignee is stamped when the task is created and is not re-inferred from whichever cloud PC happens to be online. +- Ordinary employees and team leads can read and execute their own assigned manual and AgentBus work. Administrators retain organization-wide inspection and management visibility, but confirmation, browser claim, result submission, reconciliation, and resume require the exact task assignee. +- Browser heartbeats verify the expected ERP account, register only a matching session as execution-ready, reject a second fresh worker for the same account, and permit failover only after the earlier heartbeat is stale for 90 seconds. A mismatched cloud PC is recorded as `identity_mismatch` and cannot displace a valid worker. +- Updated the account/channel administration UI, employee-only executable task feeds, assignment audit display, bridge heartbeat, and extension ERP-session probe. Released extension `0.5.164`; its probe receives the expected account and returns only the match boolean rather than page account text. +- Archived the superseded `0.5.163` ZIP and manifest snapshot under `archive/releases/2026-09-02/`; the current versioned artifact is `dist/ltjt-order-assistant-0.5.164.zip` with SHA-256 `5a59b616aa7ea2cfcc02b2c3242bfc0b424dcd6ddd28fff7ed9bcfb5b367191a`. + +## Verification + +- TypeScript no-emit check: passed. +- TypeScript build to ignored `.build/`: passed. +- Full control-plane regression: passed 158/158. +- Full legacy/platform/extension/tools regression: passed 264/264. +- Repository hygiene and release/source/hash checks: passed 10/10. +- Focused account/AgentBus routing regression: passed 29/29. +- JavaScript syntax checks for the platform app and modified extension bridge/background files: passed. +- `git diff --check`: passed. +- No live PostgreSQL migration, deployed-service smoke test, live multi-cloud-PC staging exercise, extension reload, ERP read, or ERP write was performed. + +## Follow-ups + +- Integration must reconcile the known overlap with task `20260902-kanban-filter-7e3a91c4` in `control-plane/src/task-service.ts` and `LianSyn-platform/app.js`; retain both the kanban filtering behavior and assignee-only executable feeds. +- Production rollout requires separate authorization: back up PostgreSQL, apply migration 018, deploy/restart the control plane and platform, load extension 0.5.164 on each employee cloud PC, configure each employee ERP account and route allowlist, then bind one independent AgentBus channel/key to each employee. +- Before production assurance, perform a staging matrix with at least two employee accounts/cloud PCs/ERP logins, an administrator browser left open, a deliberately mismatched ERP login, a same-account two-device conflict, 90-second stale-worker failover, and both manual and automatic AgentBus tasks. + +## Promotion Candidates + +- Target canonical documents: `.project-docs/20-architecture/system-overview.md`, `.project-docs/20-architecture/data-flow.md`, `.project-docs/40-domain/business-rules.md`, and the relevant authentication/authorization ADR during a later Integration Gate. +- Proposal: make the supported routing chain explicit as `AgentBus channel -> employee platform account -> immutable task assignee -> one fresh browser worker -> matching ERP account`; administrators manage and inspect but do not execute another assignee's work. +- Evidence: migration 018, channel owner/key gates, task-assignee access and execution checks, heartbeat worker/ERP checks, employee-only polling, updated extension probe, release 0.5.164, and the verification results above. +- Future impact: account onboarding, AgentBus key management, task APIs/SSE, automatic dispatch, cloud-PC failover, ERP identity handling, administrator workflows, deployment ordering, and staging acceptance. +- Semantic conflicts: this supersedes the prior behavior in which AgentBus tasks had no employee owner and administrators could claim globally visible work; organization-wide ERP FIFO serialization remains unchanged. Integration also has a code overlap with the kanban-filter task that is mechanical unless either task changed the meaning of active/status filters. +- Human confirmation required: yes for canonical promotion, conflict resolution if integration reveals a behavioral difference, production migration/deploy/restart, extension rollout, and live staging or ERP access. diff --git a/LianSyn-platform/app-account-form.test.mjs b/LianSyn-platform/app-account-form.test.mjs index 4537997..9cdb5a1 100644 --- a/LianSyn-platform/app-account-form.test.mjs +++ b/LianSyn-platform/app-account-form.test.mjs @@ -37,12 +37,18 @@ test('account creation accepts any non-empty password and blocks an empty passwo const empty = validate({ username: 'operator', password: '', - role: 'user' + role: 'user', + erpAccount: 'erp-operator' }); assert.equal(empty.ok, false); assert.equal(empty.field, 'accountPassword'); assert.equal(empty.message, '请输入初始密码。'); - const oneCharacter = validate({ username: 'operator', password: '1', role: 'user' }); + const oneCharacter = validate({ + username: 'operator', + password: '1', + role: 'user', + erpAccount: 'erp-operator' + }); assert.equal(oneCharacter.ok, true); const createStart = appSource.indexOf('async function createAccountFromForm()'); const createEnd = appSource.indexOf('\n}\n\nasync function updateManagedAccount', createStart); @@ -58,12 +64,14 @@ test('account creation normalizes valid form values into the server contract', ( const result = validate({ username: ' TeamLead ', password: '123456', - role: 'team_lead' + role: 'team_lead', + erpAccount: ' ERP-TeamLead ' }); assert.equal(result.ok, true); assert.equal(result.body.username, 'TeamLead'); assert.equal(result.body.password, '123456'); assert.equal(result.body.role, 'team_lead'); + assert.equal(result.body.erp_account, 'ERP-TeamLead'); assert.equal(Object.hasOwn(result.body, 'must_change_password'), false); assert.equal(Array.isArray(result.body.business_route_ids), true); assert.equal(result.body.business_route_ids.length, 0); diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 7038943..4f4d4b0 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -97,7 +97,7 @@ const persistedExtensionResultVersions = new Map(); let taskCreateInProgress = false; const AUTO_HANDOFF_RETRY_MS = 30_000; -const REQUIRED_EXTENSION_VERSION = '0.5.163'; +const REQUIRED_EXTENSION_VERSION = '0.5.164'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RECONCILE_LABEL = '只读回查 ERP 现有结果'; @@ -737,10 +737,33 @@ function renderChannelList() { heading.append(el('span', `state ${channel.status === 'connected' ? 'state-ok' : channel.status === 'error' ? 'state-bad' : 'state-warn'}`, channelStatusLabel(channel.status))); main.append(heading); main.append(el('p', 'muted', channel.external_user_ref ? `外部用户:${channel.external_user_ref}` : '未设置外部用户标识')); + main.append(el( + 'p', + channel.routing_ready ? 'muted' : 'channel-error', + channel.owner_username + ? `执行账号:${channel.owner_username} · ERP:${channel.owner_erp_account || '未配置'}` + : '未绑定平台员工账号,渠道不会接收任务' + )); main.append(el('p', 'channel-key-state', 'AgentBus key:已配置(平台不会回显)')); if (channel.last_error) main.append(el('p', 'channel-error', channel.last_error)); row.append(main); const actions = el('div', 'channel-row-actions'); + const owner = document.createElement('select'); + owner.dataset.channelAction = 'owner'; + owner.dataset.channelId = channel.id; + const availableOwners = accountList.filter((account) => ( + account.role !== 'admin' + && account.is_active + && account.erp_account + && (account.id === channel.owner_user_id || !channelList.some((item) => item.id !== channel.id && item.owner_user_id === account.id)) + )); + owner.append(new Option('选择执行账号', '')); + for (const account of availableOwners) { + owner.append(new Option(`${account.username} · ${account.erp_account}`, account.id)); + } + owner.value = channel.owner_user_id || ''; + owner.disabled = channelSettingsBusy; + actions.append(owner); const toggle = el('button', 'secondary-button', channel.enabled ? '停用' : '启用'); toggle.type = 'button'; toggle.dataset.channelAction = 'toggle'; @@ -776,9 +799,23 @@ async function syncChannels() { const result = await apiRequest('/api/channels'); channelList = Array.isArray(result.channels) ? result.channels : []; renderChannelList(); + renderChannelOwnerOptions(); return channelList; } +function renderChannelOwnerOptions() { + const select = $('#channelOwnerUserId'); + if (!select) return; + const selected = String(select.value || ''); + select.replaceChildren(new Option('请选择员工账号', '')); + const boundIds = new Set(channelList.map((channel) => channel.owner_user_id).filter(Boolean)); + for (const account of accountList) { + if (account.role === 'admin' || !account.is_active || !account.erp_account || boundIds.has(account.id)) continue; + select.append(new Option(`${account.username} · ERP ${account.erp_account}`, account.id)); + } + if ([...select.options].some((option) => option.value === selected)) select.value = selected; +} + async function createChannelFromForm() { if (!authUser || channelSettingsBusy) return; const message = $('#channelMessage'); @@ -789,6 +826,7 @@ async function createChannelFromForm() { method: 'POST', body: { display_name: normalizeText($('#channelDisplayName')?.value).slice(0, 120), + owner_user_id: String($('#channelOwnerUserId')?.value || ''), external_user_ref: normalizeText($('#channelExternalUserRef')?.value).slice(0, 200), agentbus_key: String($('#channelAgentbusKey')?.value || '').trim(), bot_address: normalizeText($('#channelBotAddress')?.value).slice(0, 200), @@ -798,9 +836,11 @@ async function createChannelFromForm() { if (result.channel) channelList = [...channelList.filter((item) => item.id !== result.channel.id), result.channel]; $('#channelAgentbusKey').value = ''; $('#channelDisplayName').value = ''; + $('#channelOwnerUserId').value = ''; $('#channelExternalUserRef').value = ''; $('#channelBotAddress').value = ''; renderChannelList(); + renderChannelOwnerOptions(); if (message) message.textContent = '渠道已保存,连接状态会在服务端异步更新。'; } finally { channelSettingsBusy = false; @@ -821,6 +861,23 @@ async function updateChannelEnabled(channelId, enabled) { } } +async function updateChannelOwner(channelId, ownerUserId) { + if (!authUser || channelSettingsBusy || !ownerUserId) return; + channelSettingsBusy = true; + try { + await apiRequest(`/api/channels/${encodeURIComponent(channelId)}`, { + method: 'PATCH', + body: { owner_user_id: ownerUserId } + }); + await syncChannels(); + renderChannelOwnerOptions(); + } finally { + channelSettingsBusy = false; + renderChannelList(); + renderChannelOwnerOptions(); + } +} + async function renameChannel(channelId) { if (!authUser || channelSettingsBusy) return; const channel = channelList.find((item) => item.id === channelId); @@ -893,6 +950,7 @@ function validateAccountCreationValues(values = {}) { const username = String(values.username || '').trim(); const password = String(values.password || ''); const role = String(values.role || ''); + const erpAccount = String(values.erpAccount || '').trim(); if (!username || username.length > 160) { return { ok: false, field: 'accountUsername', message: '账号必须为 1—160 个字符。' }; } @@ -902,12 +960,16 @@ function validateAccountCreationValues(values = {}) { if (!['admin', 'team_lead', 'user'].includes(role)) { return { ok: false, field: 'accountRole', message: '请选择有效的账号角色。' }; } + if (role !== 'admin' && (!erpAccount || erpAccount.length > 200)) { + return { ok: false, field: 'accountErpAccount', message: '普通用户或组长必须填写 1—200 个字符的 ERP 账号。' }; + } return { ok: true, body: { username, password, role, + erp_account: role === 'admin' ? '' : erpAccount, business_route_ids: [] } }; @@ -925,6 +987,9 @@ function accountCreationErrorMessage(error) { if (details.some((path) => path === 'role' || path.startsWith('role.'))) { return '请选择有效的账号角色。'; } + if (details.some((path) => path === 'erp_account' || path.startsWith('erp_account.'))) { + return '普通用户或组长必须填写 ERP 账号。'; + } } return error?.message || String(error); } @@ -940,7 +1005,7 @@ function showAccountCreationValidationError(validation) { } function clearAccountCreationValidationErrors() { - for (const fieldId of ['accountUsername', 'accountPassword', 'accountRole']) { + for (const fieldId of ['accountUsername', 'accountPassword', 'accountRole', 'accountErpAccount']) { document.getElementById(fieldId)?.removeAttribute('aria-invalid'); } } @@ -964,6 +1029,9 @@ function renderAccounts() { heading.append(el('span', `state ${account.is_active ? 'state-ok' : 'state-bad'}`, account.is_active ? '有效' : '已停用')); main.append(heading); main.append(el('p', 'muted', accountRoleLabel(account.role))); + main.append(el('p', account.erp_account ? 'muted' : 'channel-error', account.role === 'admin' + ? 'ERP 执行身份:不绑定' + : `ERP 执行身份:${account.erp_account || '未配置(禁止执行)'}`)); const authorizedCount = Array.isArray(account.authorized_business_route_ids) ? account.authorized_business_route_ids.length : 0; @@ -1002,7 +1070,12 @@ function renderAccounts() { authorizations.dataset.accountAction = 'business-authorizations'; authorizations.dataset.accountId = account.id; authorizations.disabled = accountSettingsBusy || account.role === 'admin'; - actions.append(role, authorizations, toggle, reset, revoke); + const erpAccount = el('button', 'secondary-button', 'ERP 账号'); + erpAccount.type = 'button'; + erpAccount.dataset.accountAction = 'erp-account'; + erpAccount.dataset.accountId = account.id; + erpAccount.disabled = accountSettingsBusy || account.role === 'admin'; + actions.append(role, authorizations, erpAccount, toggle, reset, revoke); row.append(main, actions); container.append(row); } @@ -1098,6 +1171,7 @@ async function syncAccounts() { accountList = Array.isArray(result.accounts) ? result.accounts : []; accountTaskTypes = Array.isArray(result.task_types) ? result.task_types : []; renderAccounts(); + renderChannelOwnerOptions(); } async function createAccountFromForm() { @@ -1105,7 +1179,8 @@ async function createAccountFromForm() { const validation = validateAccountCreationValues({ username: $('#accountUsername').value, password: $('#accountPassword').value, - role: $('#accountRole').value + role: $('#accountRole').value, + erpAccount: $('#accountErpAccount').value }); if (!validation.ok) { showAccountCreationValidationError(validation); @@ -1124,6 +1199,7 @@ async function createAccountFromForm() { .sort((left, right) => left.username.localeCompare(right.username, 'zh-CN')); $('#accountUsername').value = ''; $('#accountPassword').value = ''; + $('#accountErpAccount').value = ''; if (message) { message.textContent = result.account?.role === 'admin' ? '管理员账号已创建;该角色固定拥有全部任务权限,初始密码不会再次显示。' @@ -1135,6 +1211,14 @@ async function createAccountFromForm() { } } +async function updateManagedAccountErpIdentity(accountId) { + const account = accountList.find((item) => item.id === accountId); + if (!account || account.role === 'admin') return; + const value = window.prompt('请输入 ERP 页面显示的登录账号。修改后该员工的现有平台会话和云电脑执行连接会失效:', account.erp_account || ''); + if (value === null || !value.trim() || value.trim() === account.erp_account) return; + await updateManagedAccount(accountId, { erp_account: value.trim().slice(0, 200) }); +} + async function updateManagedAccount(accountId, patch) { if (!isAdministrator() || accountSettingsBusy) return; accountSettingsBusy = true; @@ -1807,12 +1891,12 @@ function runtimeTasks() { } function hasPollableRuntimeTasks() { - return runtimeTasks().some(isTaskPollable); + return runtimeTasks().some((task) => taskAssignedToCurrentAccount(task) && isTaskPollable(task)); } async function syncRuntimeTasks() { if (!authUser) return; - const result = await apiRequest('/api/tasks?status=active&limit=200&include_total=false'); + const result = await apiRequest('/api/tasks?status=active&limit=200&include_total=false&executable_by=me'); const activeTasks = (Array.isArray(result.tasks) ? result.tasks : []) .map(mergeRemoteTask) .filter((task) => !locallyDeletedTaskIds.has(task?.task_id)); @@ -2162,6 +2246,10 @@ function requiresManualConfirmation(task) { return ['awaiting_confirmation', 'agent_parse_passed'].includes(status); } +function taskAssignedToCurrentAccount(task) { + return Boolean(authUser?.id && task?.assignee?.id === authUser.id); +} + function taskCanonicalStatus(task) { return String(task?.status || task?.result?.status || 'created'); } @@ -2496,13 +2584,11 @@ async function autoDispatchReadyTasks({ force = false } = {}) { if (!authUser || autoHandoffInProgress) return; autoHandoffInProgress = true; try { - const result = await apiRequest('/api/tasks?status=confirmed&limit=200&include_total=false'); - const byId = new Map(taskStore.map((task) => [task.task_id, task])); - for (const task of Array.isArray(result.tasks) ? result.tasks : []) { - byId.set(task.task_id, task); + const result = await apiRequest('/api/tasks?status=confirmed&limit=200&include_total=false&executable_by=me'); + const candidates = Array.isArray(result.tasks) ? result.tasks : []; + for (const task of candidates) { cacheRuntimeTask(task); } - const candidates = [...byId.values()]; const orderedCandidates = [...candidates].sort((left, right) => { const leftTime = Date.parse(left?.created_at || '') || 0; const rightTime = Date.parse(right?.created_at || '') || 0; @@ -3498,6 +3584,7 @@ function renderTaskInputAudit(task) { const panel = el('section', 'task-output-panel'); panel.append(el('div', 'task-output-label', '创建人与原始输入审计')); panel.append(el('p', 'muted', `创建人:${task.creator?.username || (task.source === 'agentbus' ? 'AgentBus / 系统' : '历史记录未知')}`)); + panel.append(el('p', task.assignee?.username ? 'muted' : 'channel-error', `执行归属:${task.assignee?.username || '未分配(禁止 ERP 执行)'}`)); const history = taskInputHistoryStore.get(task.task_id); if (!history) { panel.append(el('p', 'muted', '正在加载加密输入历史…')); @@ -3546,10 +3633,11 @@ function renderTaskDetail() { const importantMessagePanel = renderTaskImportantMessage(task); renderTaskStages(task); if (confirmButton) { - const canConfirm = Boolean(task && requiresManualConfirmation(task)); - const canStart = Boolean(task && !isAutomaticTask(task) && canStartConfirmedTask(task)); - const canResume = Boolean(task && canResumePrewriteTask(task)); - const canReconcile = Boolean(task && canReconcileTask(task)); + const canOperate = Boolean(task && taskAssignedToCurrentAccount(task)); + const canConfirm = Boolean(canOperate && requiresManualConfirmation(task)); + const canStart = Boolean(canOperate && !isAutomaticTask(task) && canStartConfirmedTask(task)); + const canResume = Boolean(canOperate && canResumePrewriteTask(task)); + const canReconcile = Boolean(canOperate && canReconcileTask(task)); const reconciliationBusy = Boolean(task && taskReconciliationStates.get(task.task_id) === 'running'); confirmButton.hidden = !canConfirm && !canStart && !canResume && !canReconcile; confirmButton.disabled = (!canConfirm && !canStart && !canResume && !canReconcile) || reconciliationBusy; @@ -4233,6 +4321,9 @@ function renderStatusDetails() { statusDetailRow('ERP 自动化', statusBoolean(status?.automationEnabled, '已开启', '已关闭')), statusDetailRow('ERP 页面权限', statusBoolean(status?.erpHostAccessReady, '可访问', '需在扩展设置中允许')), statusDetailRow('ERP 会话', statusBoolean(status?.erpSessionReady, '正常', '异常或待登录')), + statusDetailRow('ERP 账号匹配', authUser?.erp_account + ? statusBoolean(status?.erpAccountMatched, '与平台绑定一致', `应登录 ${authUser.erp_account}`) + : '管理员不绑定员工 ERP 账号'), statusDetailRow('ERP 链路恢复', recoveryStatus || '未触发'), statusDetailRow('安装时间', formatStatusTime(status?.installedAt)), statusDetailRow('最近错误', status?.error || '无'), @@ -4317,8 +4408,10 @@ function applyBridgePayload(payload = {}) { 'erp_page_access_denied' ]).has(String(erpSession.error_code || '')); erpHostAccessReady = erpSession.host_permission_granted !== false && !erpAccessError; + const erpAccountMatched = !authUser?.erp_account || erpSession.account_matched === true; erpSessionReady = erpSession.session_ready !== false - && erpSession.erp_keepalive?.last_status !== 'session_expired'; + && erpSession.erp_keepalive?.last_status !== 'session_expired' + && erpAccountMatched; const recoveryStatus = String(erpSession.erp_link_recovery?.status || erpSession.erp_keepalive?.recovery_status || ''); const erpLinkRecoveryPending = ['scheduled', 'already_running', 'running'].includes(recoveryStatus); const parseVersion = (value) => String(value || '').split('.').map((part) => Number(part) || 0); @@ -4340,6 +4433,7 @@ function applyBridgePayload(payload = {}) { automationEnabled: erpAutomationEnabled, erpHostAccessReady, erpSessionReady, + erpAccountMatched, erpSession, erpLinkRecovery: { status: recoveryStatus || 'not_started', @@ -4353,7 +4447,9 @@ function applyBridgePayload(payload = {}) { ? 'ERP 自动化开关已关闭。' : !erpHostAccessReady ? erpSession.message || '扩展暂时没有 ERP 页面访问权限。' - : !erpSessionReady + : !erpAccountMatched + ? `当前 ERP 登录账号与平台绑定的“${authUser?.erp_account || ''}”不一致。` + : !erpSessionReady ? erpSession.message || 'ERP 会话异常,插件已尝试刷新现有 ERP 链路;请确认 ERP 已登录。' : erpLinkRecoveryPending ? '已检测到 ERP 链路异常,插件正在刷新并进行只读复测。' @@ -4562,6 +4658,9 @@ async function parseRawInstruction(rawText, taskId) { } async function handoffTaskToExtension(task) { + if (!taskAssignedToCurrentAccount(task)) { + throw new Error('该任务分配给其他平台账号,当前云电脑只能查看,不能执行。'); + } const automaticTask = isAutomaticTask(task); if (!task?.confirmed_at) { throw new Error(automaticTask ? '自动任务缺少确认时间,已停止下发。' : '任务尚未人工确认,不能提交到 ERP 插件。'); @@ -4735,6 +4834,9 @@ async function handoffTaskToExtension(task) { async function confirmAndSubmitToErpPlugin(task) { task = await ensureTaskDetails(task?.task_id); if (!requiresManualConfirmation(task)) return; + if (!taskAssignedToCurrentAccount(task)) { + throw new Error('该任务分配给其他平台账号,当前账号只能查看,不能确认或执行。'); + } const bridgeReady = await pingBridge(); if (!bridgeReady || !extensionCompatible || !erpAutomationEnabled || !erpHostAccessReady || !erpSessionReady || latestBridgeStatus?.erpLinkRecovery?.pending === true) { throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION}、ERP 页面/会话不可用或 ERP 操作开关未开启;任务尚未确认,也未提交到插件。`); @@ -4767,19 +4869,49 @@ async function confirmAndSubmitToErpPlugin(task) { async function pingBridge() { try { - const result = await sendToExtension('PING', {}, 1200); + const result = await sendToExtension('PING', { + expected_erp_account: authUser?.erp_account || '' + }, 1200); applyBridgePayload(result); if (authUser && result?.ok) { - await apiRequest('/api/connections/heartbeat', { - method: 'POST', - body: { - connection_id: browserConnectionId, - extension_version: result.version || '', - metadata: { bridge_installed_at: result.bridge_installed_at || '' } + try { + const heartbeat = await apiRequest('/api/connections/heartbeat', { + method: 'POST', + body: { + connection_id: browserConnectionId, + extension_version: result.version || '', + erp_account: authUser.erp_account || '', + erp_account_matched: result.erp_session?.account_matched === true, + metadata: { bridge_installed_at: result.bridge_installed_at || '' } + } + }); + if (heartbeat.execution_ready !== true) { + erpSessionReady = false; + latestBridgeStatus = { + ...latestBridgeStatus, + erpSessionReady: false, + erpAccountMatched: heartbeat.erp_account_matched === true, + error: authUser.erp_account + ? `当前 ERP 登录账号与平台绑定的“${authUser.erp_account}”不一致。` + : '当前云电脑尚未满足执行条件。' + }; + setBridgeState('已连接,有告警', 'state-warn'); + renderStatusDetails(); + return false; } - }); + } catch (heartbeatError) { + erpSessionReady = false; + latestBridgeStatus = { + ...latestBridgeStatus, + erpSessionReady: false, + error: heartbeatError.message || String(heartbeatError) + }; + setBridgeState('已连接,有告警', 'state-warn'); + renderStatusDetails(); + return false; + } } - return bridgeConnected; + return bridgeConnected && erpSessionReady; } catch (error) { bridgeConnected = false; erpAutomationEnabled = false; @@ -5094,6 +5226,7 @@ async function pollTaskResult(taskId, { allowReconciliation = false } = {}) { async function reconcileTaskReceipt(task) { if (!canReconcileTask(task)) throw new Error('当前任务不在可只读回查的生命周期不确定状态。'); + if (!taskAssignedToCurrentAccount(task)) throw new Error('该任务分配给其他平台账号,当前云电脑不能执行回查。'); const bridgeReady = await pingBridge(); if (!bridgeReady || !extensionCompatible || !erpHostAccessReady) throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION} 或 ERP 页面权限不可用,未执行回查。`); taskReconciliationStates.set(task.task_id, 'running'); @@ -5112,6 +5245,7 @@ async function reconcileTaskReceipt(task) { async function resumePrewriteTaskExecution(task) { if (!canResumePrewriteTask(task)) throw new Error('当前任务没有可证明的保存前零写状态,禁止恢复。'); + if (!taskAssignedToCurrentAccount(task)) throw new Error('该任务分配给其他平台账号,当前云电脑不能恢复执行。'); const response = await sendToExtension('RESUME_PREWRITE_TASK', { task_id: task.task_id }, 3000); updateLocalTask(task.task_id, { status: 'running', @@ -5128,7 +5262,7 @@ async function pollAllTaskResults() { if (!bridgeConnected || pollInProgress) return; pollInProgress = true; try { - const activeTasks = runtimeTasks().filter(isTaskPollable); + const activeTasks = runtimeTasks().filter((task) => taskAssignedToCurrentAccount(task) && isTaskPollable(task)); for (const task of activeTasks) { try { await pollTaskResult(task.task_id); @@ -5213,6 +5347,7 @@ async function initializeSession() { await syncParserRouting().catch(() => {}); } if (IS_CHANNELS_PAGE && isAdministrator()) { + await syncAccounts().catch(() => {}); await syncChannels().catch((error) => { const message = $('#channelMessage'); if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; @@ -5306,10 +5441,13 @@ document.addEventListener('DOMContentLoaded', async () => { await syncAutomationSettings().catch(() => setTaskState('全自动化设置读取失败')); } if (IS_PARSER_ROUTING_PAGE && isAdministrator()) await syncParserRouting().catch(() => {}); - if (IS_CHANNELS_PAGE && isAdministrator()) await syncChannels().catch((error) => { - const message = $('#channelMessage'); - if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; - }); + if (IS_CHANNELS_PAGE && isAdministrator()) { + await syncAccounts().catch(() => {}); + await syncChannels().catch((error) => { + const message = $('#channelMessage'); + if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; + }); + } if (IS_ACCOUNTS_PAGE && isAdministrator()) await syncAccounts(); if (IS_AUDIT_PAGE && isAdministrator()) await syncAuditEvents(); if (IS_TASK_PAGE) { @@ -5480,7 +5618,19 @@ document.addEventListener('DOMContentLoaded', async () => { $('#accountList')?.addEventListener('change', (event) => { const control = event.target.closest('[data-account-action="role"][data-account-id]'); if (!control) return; - void updateManagedAccount(control.dataset.accountId, { role: control.value }).catch((error) => { + const account = accountList.find((item) => item.id === control.dataset.accountId); + if (!account) return; + const patch = { role: control.value }; + if (control.value === 'admin') patch.erp_account = null; + if (control.value !== 'admin' && !account.erp_account) { + const erpAccount = window.prompt('该员工角色必须绑定 ERP 账号,请输入 ERP 页面显示的登录账号:', ''); + if (!erpAccount?.trim()) { + control.value = account.role; + return; + } + patch.erp_account = erpAccount.trim().slice(0, 200); + } + void updateManagedAccount(control.dataset.accountId, patch).catch((error) => { const message = $('#accountMessage'); if (message) message.textContent = error.message || String(error); void syncAccounts(); @@ -5497,7 +5647,8 @@ document.addEventListener('DOMContentLoaded', async () => { openAccountAuthorizationEditor(account.id); return; } - if (action === 'toggle') operation = updateManagedAccount(account.id, { is_active: !account.is_active }); + if (action === 'erp-account') operation = updateManagedAccountErpIdentity(account.id); + else if (action === 'toggle') operation = updateManagedAccount(account.id, { is_active: !account.is_active }); else if (action === 'reset-password') operation = resetManagedAccountPassword(account.id); else if (action === 'revoke-sessions') operation = revokeManagedAccountSessions(account.id); else return; @@ -5656,6 +5807,22 @@ document.addEventListener('DOMContentLoaded', async () => { if (message) message.textContent = error.message || String(error); }); }); + $('#channelList')?.addEventListener('change', (event) => { + const select = event.target.closest('select[data-channel-action="owner"][data-channel-id]'); + if (!select?.value) return; + void updateChannelOwner(select.dataset.channelId, select.value).catch((error) => { + const message = $('#channelMessage'); + if (message) message.textContent = error.message || String(error); + void syncChannels(); + }); + }); + $('#accountRole')?.addEventListener('change', (event) => { + const erpInput = $('#accountErpAccount'); + const admin = event.target.value === 'admin'; + erpInput.required = !admin; + erpInput.disabled = admin; + if (admin) erpInput.value = ''; + }); $('#aiState').addEventListener('click', () => { toggleStatusDetails('ai').catch(() => {}); }); diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index 3cb35c6..04f6070 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -91,13 +91,14 @@
ACCOUNT DIRECTORY
管理员可维护账号、密码与可执行任务类型;未授权指令会在解析和 ERP 执行前被阻止。
+员工账号必须绑定唯一 ERP 账号;管理员只负责管理和查看,不绑定员工 ERP 执行身份。
CHANNEL DIRECTORY
每个外部用户渠道使用独立 AgentBus key;key 只写入服务端,不会回显,不再使用的渠道可以删除。
+每个渠道必须一对一绑定有效员工账号;入站任务只会交给该账号当前唯一在线且 ERP 身份匹配的云电脑。