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 5bc8309..a092507 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; @@ -1819,12 +1903,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)); @@ -2174,6 +2258,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'); } @@ -2508,13 +2596,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; @@ -3510,6 +3596,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', '正在加载加密输入历史…')); @@ -3558,10 +3645,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; @@ -4245,6 +4333,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 || '无'), @@ -4329,8 +4420,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); @@ -4352,6 +4445,7 @@ function applyBridgePayload(payload = {}) { automationEnabled: erpAutomationEnabled, erpHostAccessReady, erpSessionReady, + erpAccountMatched, erpSession, erpLinkRecovery: { status: recoveryStatus || 'not_started', @@ -4365,7 +4459,9 @@ function applyBridgePayload(payload = {}) { ? 'ERP 自动化开关已关闭。' : !erpHostAccessReady ? erpSession.message || '扩展暂时没有 ERP 页面访问权限。' - : !erpSessionReady + : !erpAccountMatched + ? `当前 ERP 登录账号与平台绑定的“${authUser?.erp_account || ''}”不一致。` + : !erpSessionReady ? erpSession.message || 'ERP 会话异常,插件已尝试刷新现有 ERP 链路;请确认 ERP 已登录。' : erpLinkRecoveryPending ? '已检测到 ERP 链路异常,插件正在刷新并进行只读复测。' @@ -4574,6 +4670,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 插件。'); @@ -4747,6 +4846,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 操作开关未开启;任务尚未确认,也未提交到插件。`); @@ -4779,19 +4881,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; @@ -5106,6 +5238,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'); @@ -5124,6 +5257,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', @@ -5140,7 +5274,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); @@ -5225,6 +5359,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)}`; @@ -5318,10 +5453,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) { @@ -5492,7 +5630,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(); @@ -5509,7 +5659,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; @@ -5668,6 +5819,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 e487b0c..7cac8ac 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -91,13 +91,14 @@

ACCOUNT DIRECTORY

平台账号管理

-

管理员可维护账号、密码与可执行任务类型;未授权指令会在解析和 ERP 执行前被阻止。

+

员工账号必须绑定唯一 ERP 账号;管理员只负责管理和查看,不绑定员工 ERP 执行身份。

+
- + diff --git a/agent设计规范/agentbus-reply-contract.md b/agent设计规范/agentbus-reply-contract.md index 57e0a7c..6808d33 100644 --- a/agent设计规范/agentbus-reply-contract.md +++ b/agent设计规范/agentbus-reply-contract.md @@ -2,6 +2,14 @@ 本契约只约束执行完成后发给 AgentBus 用户的业务回执,不改变 Agent/Skill 的解析 JSON 契约。 +## 归属和执行路由 + +每个 AgentBus 渠道必须一对一绑定一个有效的普通用户或组长平台账号;管理员负责配置和查看,但不能成为员工渠道的归属账号,也不能代替归属账号确认或领取 ERP 执行。渠道只有在归属账号已配置唯一 ERP 账号后才能启用 listener。AgentBus 入站任务在创建时同时固化渠道与 `assigned_user_id`,使用归属账号当时有效的业务类型白名单;不得在领取时按当前在线浏览器、用户名、管理员身份或任意空闲云电脑重新推断归属。 + +员工云电脑必须同时登录该员工的平台账号和平台绑定的 ERP 账号并打开扩展。同一平台账号在 90 秒心跳新鲜期内只允许一个执行 worker;扩展只回传期望 ERP 账号是否匹配,控制面不接收页面中的其他账号文本。领取任务和提交结果都会重新校验任务归属、worker 连接与 ERP 身份。任一项缺失、过期、冲突或不匹配都失败关闭,不把任务转交给管理员或其他在线员工。 + +旧未绑定渠道默认停用,历史上无法可靠推断归属的 AgentBus 任务保持未分配且不得自动执行。渠道删除后,历史任务保留原 `assigned_user_id`,该渠道尚未投递的回执随渠道删除且不得改投其他渠道。 + ## 用户侧消息 用户侧只收到必要的阶段消息: diff --git a/agent设计规范/business-adaptation-registry.md b/agent设计规范/business-adaptation-registry.md index f96a3bb..143557c 100644 --- a/agent设计规范/business-adaptation-registry.md +++ b/agent设计规范/business-adaptation-registry.md @@ -1,6 +1,6 @@ # 业务适配登记表 -这是跨会话和交付的业务入口。它回答三个问题:业务人员怎么输入、ERP 怎么执行、Skill 在哪里维护。运营统一复制入口见[运营业务 AI 输入模板总表](templates/business-input-templates.md),AI 业务路由参考见 [business-behavior-registry.md](business-behavior-registry.md),18 项确定性解析的指令、字段、action 和版本唯一机器源见 [`control-plane/src/business-routes.ts`](../control-plane/src/business-routes.ts)。该机器路由、任务级解析模式快照和组织全自动化规则对手工与 AgentBus 新任务全局共用,来源只负责输入和回执适配。 +这是跨会话和交付的业务入口。它回答三个问题:业务人员怎么输入、ERP 怎么执行、Skill 在哪里维护。运营统一复制入口见[运营业务 AI 输入模板总表](templates/business-input-templates.md),AI 业务路由参考见 [business-behavior-registry.md](business-behavior-registry.md),18 项确定性解析的指令、字段、action 和版本唯一机器源见 [`control-plane/src/business-routes.ts`](../control-plane/src/business-routes.ts)。该机器路由、任务级解析模式快照和组织全自动化规则对手工与 AgentBus 新任务全局共用,来源只负责输入和回执适配;AgentBus 渠道必须绑定一个员工平台账号,并使用该账号的业务白名单、任务归属、唯一云电脑 worker 和 ERP 身份门禁。 ## 状态定义 diff --git a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md index 661de18..9dbc77c 100644 --- a/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md +++ b/agent设计规范/test-fixtures/lwlt-lifecycle/release-gate.md @@ -2,11 +2,11 @@ ## 当前基线 -- Chrome 插件:`0.5.163` +- Chrome 插件:`0.5.164` - Agent Prompt:`ltjt-agent-prompt-v1.8-independent-headcount-categories` - 生命周期契约:`ltjt-lifecycle-v2.9-roster-leader-contact-2026-08` - 五个 Skill:`0.5.125`;运营 DOCX:`0.5.125` -- 发布状态:核心窄能力已有真实证据;一笔名单附件 Program-only 任务已把 25 行及唯一领队联系人正确保存到 ERP,后续只读逐字段核验全等。两种写后逐行读取方式都曾在真实成功写入后产生假阴性;名单在全部写前门禁通过后,以父表单 ERP 明确成功响应作为终结证据,不再执行写后逐行回查或自动名单对账。`0.5.155` 规定确认覆盖的指定序号同值也写;`0.5.156` 把原生游客位不足统一映射为包含名单人数和 ERP 实际上限的业务提示,原技术 blocker 仅留技术详情。`0.5.157` 新增散拼母团“整团游客信息”的 `shared_plan + visitor-list + tid-only` 窄分支,静态契约与回归已通过,仍待授权后的新版运行态只读复测。`0.5.158` 把正式控制面 `https://lwlt.nianxx.cn` 同步加入 Popup、后台、Manifest 权限和 content script 精确白名单;其他 HTTPS origin 仍保持阻断。`0.5.159` 修复 MV3 后台恢复被中断后持久化 `running` 永久阻断插件派发的问题:仅在内存恢复 Promise 已不存在时收敛为 `interrupted`,真实活动恢复仍保持阻断,下一次成功只读保活把状态归零为 `idle`。`0.5.160` 为独立团单个下单写前漂移增加仅字段名诊断,并把数字后缀动态应收字段纳入保护。`0.5.161` 等待客户、跟单人和销售人原生 lookup 数据就绪后才开始单团预检;生产反馈证明仅检查 lookup 数据仍不足。`0.5.162` 进一步要求页面 jQuery Ajax 在预检前归零,并在 `GetProduct` 后等待原生 `ajaxStop` 再应用最终业务字段。`0.5.163` 按生产操作方明确要求恢复 `0.5.156` 的实际校验范围:核心订单字段继续写前阻断,带编号的动态 `ys_*` 应收行不再纳入保护哈希,允许 ERP 原生回调在预检后调整这些字段。新增独立团房型/人数映射仍待实写;未验证宽能力继续失败关闭。 +- 发布状态:核心窄能力已有真实证据;一笔名单附件 Program-only 任务已把 25 行及唯一领队联系人正确保存到 ERP,后续只读逐字段核验全等。两种写后逐行读取方式都曾在真实成功写入后产生假阴性;名单在全部写前门禁通过后,以父表单 ERP 明确成功响应作为终结证据,不再执行写后逐行回查或自动名单对账。`0.5.155` 规定确认覆盖的指定序号同值也写;`0.5.156` 把原生游客位不足统一映射为包含名单人数和 ERP 实际上限的业务提示,原技术 blocker 仅留技术详情。`0.5.157` 新增散拼母团“整团游客信息”的 `shared_plan + visitor-list + tid-only` 窄分支,静态契约与回归已通过,仍待授权后的新版运行态只读复测。`0.5.158` 把正式控制面 `https://lwlt.nianxx.cn` 同步加入 Popup、后台、Manifest 权限和 content script 精确白名单;其他 HTTPS origin 仍保持阻断。`0.5.159` 修复 MV3 后台恢复被中断后持久化 `running` 永久阻断插件派发的问题:仅在内存恢复 Promise 已不存在时收敛为 `interrupted`,真实活动恢复仍保持阻断,下一次成功只读保活把状态归零为 `idle`。`0.5.160` 为独立团单个下单写前漂移增加仅字段名诊断,并把数字后缀动态应收字段纳入保护。`0.5.161` 等待客户、跟单人和销售人原生 lookup 数据就绪后才开始单团预检;生产反馈证明仅检查 lookup 数据仍不足。`0.5.162` 进一步要求页面 jQuery Ajax 在预检前归零,并在 `GetProduct` 后等待原生 `ajaxStop` 再应用最终业务字段。`0.5.163` 按生产操作方明确要求恢复 `0.5.156` 的实际校验范围:核心订单字段继续写前阻断,带编号的动态 `ys_*` 应收行不再纳入保护哈希,允许 ERP 原生回调在预检后调整这些字段。`0.5.164` 新增平台账号、AgentBus 渠道、唯一云电脑 worker 与 ERP 登录身份四方一致门禁;旧未绑定渠道和历史未归属 AgentBus 任务默认不执行。新增独立团房型/人数映射仍待实写;未验证宽能力继续失败关闭。 运行中的浏览器版本必须实时握手确认。当前制品文件名和 SHA-256 只看 [`../../../dist/release-manifest.json`](../../../dist/release-manifest.json),不从历史日志推断。 diff --git a/archive/releases/2026-09-02/README.md b/archive/releases/2026-09-02/README.md new file mode 100644 index 0000000..f34bff3 --- /dev/null +++ b/archive/releases/2026-09-02/README.md @@ -0,0 +1,6 @@ +# 2026-09-02 发布归档 + +本目录只保存被新版本替代的历史发布物,不定义当前运行规则。当前制品文件名和 SHA-256 只以根目录 [`../../../dist/release-manifest.json`](../../../dist/release-manifest.json) 为准。 + +- `release-manifest-0.5.163.json`:引入账号、渠道、云电脑 worker 与 ERP 身份一致性门禁前的历史清单快照。 +- `ltjt-order-assistant-0.5.163.zip`:被 `0.5.164` 的员工账号路由和 ERP 身份核验替代;保留用于恢复和追溯。 diff --git a/dist/ltjt-order-assistant-0.5.163.zip b/archive/releases/2026-09-02/ltjt-order-assistant-0.5.163.zip similarity index 100% rename from dist/ltjt-order-assistant-0.5.163.zip rename to archive/releases/2026-09-02/ltjt-order-assistant-0.5.163.zip diff --git a/archive/releases/2026-09-02/release-manifest-0.5.163.json b/archive/releases/2026-09-02/release-manifest-0.5.163.json new file mode 100644 index 0000000..bc363a5 --- /dev/null +++ b/archive/releases/2026-09-02/release-manifest-0.5.163.json @@ -0,0 +1,69 @@ +{ + "manifest_version": 1, + "generated_on": "2026-09-01", + "baselines": { + "chrome_extension": "0.5.163", + "skills": "0.5.125", + "business_instruction_docx": "0.5.125", + "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" + }, + "artifacts": [ + { + "kind": "chrome_extension", + "version": "0.5.163", + "path": "dist/ltjt-order-assistant-0.5.163.zip", + "source": "chrome-extension/ltjt-order-assistant", + "sha256": "90c550054cdf747aa9c2c80bca5ee5fba0d13f126acfd4779f85400764379bf0" + }, + { + "kind": "skill", + "name": "lwlt-arrangement", + "version": "0.5.125", + "path": "dist/lwlt-arrangement-0.5.125.skill", + "source": "agent设计规范/skills/lwlt-arrangement", + "sha256": "c729bebec84695b8642a6d31cb4dc0d963135b6c6620c2173be095826cd3976d" + }, + { + "kind": "skill", + "name": "lwlt-confirmation", + "version": "0.5.125", + "path": "dist/lwlt-confirmation-0.5.125.skill", + "source": "agent设计规范/skills/lwlt-confirmation", + "sha256": "659f6aca7918a03a29fc78f0b4b938dfa9084ccdbb44ebd71f6399556cfa4609" + }, + { + "kind": "skill", + "name": "lwlt-lifecycle", + "version": "0.5.125", + "path": "dist/lwlt-lifecycle-0.5.125.skill", + "source": "agent设计规范/skills/lwlt-lifecycle", + "sha256": "d964c0a9e482f82757fea25d4713f0c81feb2704486583dfa84f2336ecd930c0" + }, + { + "kind": "skill", + "name": "lwlt-newbooking", + "version": "0.5.125", + "path": "dist/lwlt-newbooking-0.5.125.skill", + "source": "agent设计规范/skills/lwlt-newbooking", + "sha256": "bb610c50dd659fc172aabfa469b3028de98704c249cfd4890463deea0c88cd0c" + }, + { + "kind": "skill", + "name": "lwlt-updating", + "version": "0.5.125", + "path": "dist/lwlt-updating-0.5.125.skill", + "source": "agent设计规范/skills/lwlt-updating", + "sha256": "c6f5bc9008e2f63d170f3f140140003d99411ce5232632c48b38891da1ceee33" + }, + { + "kind": "business_instruction_docx", + "version": "0.5.125", + "path": "dist/老挝联泰AI指令表-0.5.125.docx", + "source": "agent设计规范/templates/business-input-templates.md", + "builder": "tools/build_business_instruction_docx.py", + "sha256": "a0c2fd5f808e3d3f2ac211a652a59f2e435d3824df49c51f763ca4beb7d9b97d", + "source_sha256": "27600fe9bcb0de3898a99550428a9890dd350c701ba704fb3940ca966088dcd9", + "builder_sha256": "c162884210da22e3b78368749b66f1f177df5e9fc6155f4fd9954bd516d187f1" + } + ] +} diff --git a/chrome-extension/ltjt-order-assistant/README.md b/chrome-extension/ltjt-order-assistant/README.md index 779374f..7515c86 100644 --- a/chrome-extension/ltjt-order-assistant/README.md +++ b/chrome-extension/ltjt-order-assistant/README.md @@ -11,9 +11,12 @@ Chrome Manifest V3 扩展,在用户已登录的 LTJT ERP 页面内执行经过 ## 当前版本 -当前源码版本为 `0.5.163`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 +当前源码版本为 `0.5.164`。版本化 ZIP、文件哈希和 Skill/DOCX 基线见 [`../../dist/release-manifest.json`](../../dist/release-manifest.json)。旧版本实现流水已冻结在 [`../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md`](../../archive/project-history/2026-08-16/chrome-extension-README.pre-governance.md)。 -0.5.163 当前重点: +0.5.164 当前重点: + +- 平台会把当前员工账号绑定的 ERP 账号随只读 PING 交给扩展核验;扩展只返回是否匹配,不回传页面中的其他账号文本。 +- 只有平台确认的唯一在线云电脑、匹配的 ERP 登录身份和任务归属账号三者一致时,任务才可领取或回传结果。 - 保留 `0.5.162` 的稳定态处理:独立团单个下单只有在表单结构、客户/跟单人/销售人三个原生 SelectBox lookup 数据以及页面 jQuery Ajax 全部就绪后才进入预检;`GetProduct` 外层完成后还会等待页面自身 `ajaxStop`,再应用最终业务字段。该门禁使用原生完成事件和失败超时,不使用固定 sleep,也不在 live gate 静默回填。 diff --git a/chrome-extension/ltjt-order-assistant/background.js b/chrome-extension/ltjt-order-assistant/background.js index 98426fd..07b7dac 100644 --- a/chrome-extension/ltjt-order-assistant/background.js +++ b/chrome-extension/ltjt-order-assistant/background.js @@ -1347,7 +1347,8 @@ async function findOrOpenErpTab(taskId = '') { } } -async function readErpSessionStatus() { +async function readErpSessionStatus(expectedErpAccount = '') { + const normalizedExpectedAccount = String(expectedErpAccount || '').trim(); const permission = await erpHostPermissionStatus(); const keepalive = await readErpKeepaliveState(); const tabs = await chrome.tabs.query({ url: `${ERP_ORIGIN}/*` }); @@ -1356,6 +1357,7 @@ async function readErpSessionStatus() { return { ok: false, tab_present: false, + account_configured: Boolean(normalizedExpectedAccount), account_matched: false, url: '', session_ready: null, @@ -1372,6 +1374,7 @@ async function readErpSessionStatus() { return { ok: false, tab_present: true, + account_configured: Boolean(normalizedExpectedAccount), account_matched: false, url: tab.url || '', session_ready: false, @@ -1385,16 +1388,18 @@ async function readErpSessionStatus() { try { const [inspection] = await executeErpScript(tab.id, { target: { tabId: tab.id }, - func: () => { + func: (expectedAccount) => { const bodyText = document.body?.innerText || ''; return { url: location.href, title: document.title, - account_matched: bodyText.includes('测试ai员工账号'), + account_configured: Boolean(expectedAccount), + account_matched: Boolean(expectedAccount) && bodyText.includes(expectedAccount), login_or_permission_error: Boolean(document.querySelector('input[type="password"]')) || /login|无权限|权限不足|permission denied/i.test(`${location.href}\n${document.title}\n${bodyText.slice(0, 400)}`) }; - } + }, + args: [normalizedExpectedAccount] }); const inspectionResult = inspection?.result || { url: tab.url || '', @@ -3753,7 +3758,7 @@ chrome.runtime.onConnect.addListener((port) => { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message?.type === 'LTJT_ERP_SESSION_STATUS') { - readErpSessionStatus() + readErpSessionStatus(message.expected_erp_account || '') .then((result) => sendResponse(result)) .catch((error) => sendResponse({ ok: false, diff --git a/chrome-extension/ltjt-order-assistant/business-bridge.js b/chrome-extension/ltjt-order-assistant/business-bridge.js index 42c1dae..c3873b3 100644 --- a/chrome-extension/ltjt-order-assistant/business-bridge.js +++ b/chrome-extension/ltjt-order-assistant/business-bridge.js @@ -45,7 +45,7 @@ async function getAutomationState() { }; } -async function bridgePayload(extra = {}) { +async function bridgePayload(extra = {}, expectedErpAccount = '') { let erpSession = { ok: false, tab_present: false, @@ -53,7 +53,10 @@ async function bridgePayload(extra = {}) { url: '' }; try { - erpSession = await chrome.runtime.sendMessage({ type: 'LTJT_ERP_SESSION_STATUS' }) || erpSession; + erpSession = await chrome.runtime.sendMessage({ + type: 'LTJT_ERP_SESSION_STATUS', + expected_erp_account: String(expectedErpAccount || '').trim() + }) || erpSession; } catch (error) { erpSession = { ...erpSession, message: error.message || String(error) }; } @@ -267,7 +270,7 @@ const bridgeHandler = async (event) => { const requestId = message.requestId || ''; try { if (message.type === 'PING') { - postReply(requestId, 'PONG', await bridgePayload()); + postReply(requestId, 'PONG', await bridgePayload({}, message.payload?.expected_erp_account)); return; } if (message.type === 'CREATE_TASK') { diff --git a/chrome-extension/ltjt-order-assistant/inpage.js b/chrome-extension/ltjt-order-assistant/inpage.js index 570f13d..5978c26 100644 --- a/chrome-extension/ltjt-order-assistant/inpage.js +++ b/chrome-extension/ltjt-order-assistant/inpage.js @@ -6077,7 +6077,7 @@ } window.LTJTOrderAssistant = { - version: '0.5.163', + version: '0.5.164', resolveNativeListSearchValues, lookupKeywordMatchesText, inspectLifecycleSearchCriteria: lifecycleSearchCriteria, diff --git a/chrome-extension/ltjt-order-assistant/manifest.json b/chrome-extension/ltjt-order-assistant/manifest.json index a5a80c5..181ccce 100644 --- a/chrome-extension/ltjt-order-assistant/manifest.json +++ b/chrome-extension/ltjt-order-assistant/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "联泰下单助手", - "version": "0.5.163", + "version": "0.5.164", "description": "在已登录 LTJT ERP 页面内规划并受控执行联泰 ERP 业务操作。", "permissions": [ "activeTab", diff --git a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js index ebc9030..d485b3c 100644 --- a/chrome-extension/ltjt-order-assistant/team-batch-inpage.js +++ b/chrome-extension/ltjt-order-assistant/team-batch-inpage.js @@ -919,7 +919,7 @@ window.LTJTOrderAssistant = { ...(window.LTJTOrderAssistant || {}), - version: '0.5.163', + version: '0.5.164', openTeamBatchForm, pingTeamBatchFrame, preflightTeamBatchNative, diff --git a/control-plane/README.md b/control-plane/README.md index 58e72a3..9562b44 100644 --- a/control-plane/README.md +++ b/control-plane/README.md @@ -7,27 +7,27 @@ - PostgreSQL 是任务、事件、幂等、审计和回查状态的唯一事实源。 - 平台账号使用 `admin`(管理员)、`team_lead`(组长)和 `user`(普通用户)三种固定角色登录;服务端会话使用 HttpOnly/Secure/SameSite Cookie。管理员维护账号、角色、状态、密码重置、会话撤销和可执行任务类型;平台不提供组织或租户选择。 - 登录会话默认跨浏览器重启持续有效,不按空闲时间或绝对时长自动失效;用户退出、修改密码、账号停用或管理员强制撤销时由服务端立即撤销。 -- 业务页面通过 REST 创建任务;Agent 返回结构化结果后,任务创建人在自己的任务上一次点击“确认并提交到 ERP 插件”,再通过 SSE 或轮询读取服务端状态。普通用户与组长的正常任务 API、SSE、附件和插件执行权限都只覆盖本人创建的人工任务;管理员可访问固定部署范围内的全部任务和 AgentBus/system 任务。 +- 业务页面通过 REST 创建任务;Agent 返回结构化结果后,任务归属人在自己的任务上一次点击“确认并提交到 ERP 插件”,再通过 SSE 或轮询读取服务端状态。普通用户与组长的任务 API、SSE、附件和插件执行权限覆盖分配给本人的人工任务与 AgentBus 任务;管理员可查看固定部署范围内的全部任务,但确认、插件领取和执行回执仍必须来自任务归属账号,查看权限不等于执行权限。 - 手工与 AgentBus 的每个新任务都会按同一组织、同一 18 项业务路由固化解析策略及配置 revision;来源不能覆盖模式。其中两项名单 route 固定为 `program_only`,其余 16 项可配置 `ai / shadow / auto / program`。全消息中的唯一已登记指令可以确定 route;没有指令时,只有全部标签都属于唯一 route、至少两个不同标签且业务定位必填项完整的字段签名才可确定 route。未知、冲突或多 route 输入不猜测。后续补充轮次沿用原任务快照,设置变化只影响新任务和新会话。 -- 管理员天然拥有全部 18 类人工业务。组长和普通用户使用逐账号白名单,新账号默认没有任何可执行任务类型;管理员在 `/accounts` 逐项授权后才能提交对应的新任务、补充指令或名单附件。已登记但未授权的业务返回 `business_not_authorized`;无法唯一确认 route 的非管理员人工输入返回 `business_type_unresolved`。两种拒绝都发生在解析器和 ERP 插件之前,并记录不含明文的授权拒绝审计。权限在补充输入、人工确认、全自动确认和插件领取前再次校验;取消授权后的任务不会进入 ERP 队列。AgentBus 继续使用管理员控制的独立渠道边界,不映射为平台普通账号权限。 +- 管理员天然拥有全部 18 类人工业务。组长和普通用户使用逐账号白名单,新账号默认没有任何可执行任务类型;管理员在 `/accounts` 逐项授权后才能提交对应的新任务、补充指令或名单附件。已登记但未授权的业务返回 `business_not_authorized`;无法唯一确认 route 的非管理员输入返回 `business_type_unresolved`。两种拒绝都发生在解析器和 ERP 插件之前,并记录不含明文的授权拒绝审计。权限在补充输入、人工确认、全自动确认和插件领取前再次校验;取消授权后的任务不会进入 ERP 队列。AgentBus 入站使用渠道绑定员工的同一白名单,管理员账号不得成为员工渠道归属人。 - 名单 route 创建后先进入 `awaiting_attachment`,只接收一份 `.xls/.xlsx`;控制面在前 100 行中自动定位唯一的 ERP 名单字段表头,按精确字段语义映射任意表头行、列顺序及受支持别名,并把表头下方连续名单数据在内存中失败关闭地规范化为 13 列 canonical TSV。未知额外列、重复语义字段和多个候选表头继续阻断。原始工作簿不入库,文件名和 canonical TSV 使用字段加密,解析通过或终止后清除 canonical 中间文本。附件到齐前不会领取解析任务,也不会进入 ERP。独立团初始 16 行、散拼子单初始 31 行仅为 ERP 动态扩行基线,5000 为技术上限。 - 平台的任务 ID、Agent 会话 ID、确认状态、重要摘要、事件和用户通讯内容只存在于平台任务/会话/结果信封中,不回写到 Agent `operation`,也不成为 ERP 业务字段。 - Agent `operation` 只保存解析态业务事实。插件领取已确认任务后先做无需 ERP 查询的前门禁,再在 ERP 内只读唯一解析对象、资源和当前状态,最后对内部 execution operation 执行严格写前门禁。 -- Chrome 插件仍在当前平台账号已登录的浏览器/ERP 会话中工作;浏览器连接绑定该平台账号,`chrome.storage.local` 只是临时缓存。 +- Chrome 插件仍在当前平台账号已登录的浏览器/ERP 会话中工作;每个员工平台账号必须配置唯一 ERP 账号,同一平台账号在 90 秒新鲜期内只允许一台云电脑保持执行 worker。心跳携带扩展对期望 ERP 账号的只读匹配结果;账号不匹配、心跳过期、第二台并发云电脑或任务归属不一致都会在领取和回执两端失败关闭。`chrome.storage.local` 只是临时缓存。 - `confirmation_export` 先按对象范围选择 ERP 源:独立团/散拼具体子单的游客名单使用 `did+tid`,散拼母团的整团游客信息只使用 `tid`,且母团只开放这一单一文件类型。附件在 `TaskArtifactStore` 写入前按类型处理:游客信息保留原始 ERP `.xls` 作为源归档,同时由平台通过 LibreOffice 生成真实 `.xlsx`;AgentBus/微信只投递 `.xlsx`,若 XLSX 转换失败则阻断外部附件交付。其他类型由平台转换为 PDF,转换失败回退对应 ERP 源文件。生产附件写入 OSS;PostgreSQL 只保存任务归属、文件元数据和 OSS object key。OSS Bucket 按当前生产策略公共可读,但只有后端凭据可写/删;对象 URL 使用随机执行 UUID,不包含客户名或团号。 -- ERP 写入继续遵循预检、任务创建人确认(或管理员配置的全自动化)、单次提交、ERP 回查;不确定结果禁止自动重试。 -- 由于插件不使用独立凭据,执行期间必须保持任务创建人的业务页面和已登录 ERP 浏览器会话可用;页面断开不会触发自动补偿或重复提交。 +- ERP 写入继续遵循预检、任务归属人确认(或管理员配置的全自动化)、单次提交、ERP 回查;不确定结果禁止自动重试。 +- 由于插件不使用独立凭据,执行期间必须保持任务归属人的业务页面和已登录 ERP 浏览器会话可用;页面断开不会触发自动补偿或重复提交。 - 服务端先创建唯一 ERP execution,再允许页面向插件下发;同一任务只有一个 `erp` attempt。刷新、重连、超时和迟到回执都不能创建第二次执行。 - 插件回执必须携带服务端 `execution_id` 和领取连接;完成、阻断及待回查状态不可被后续 `running` 回执覆盖。执行租约过期会进入待回查,不会重新入队。 - Chrome 插件最低兼容版本由正式操作台与根目录发布清单共同门禁。插件包含分段前门禁、ERP 只读唯一解析、严格写前门禁、当前窄生命周期适配、写入前 `write_started` 持久化和写后回查;扩展后台重启后也不会重跑同一任务。本次双轨解析不修改插件执行契约或扩展版本。 - 渠道 Adapter 由 AgentBus 负责;控制平面只作为 AgentBus Bot 连接到文档中的 WebSocket,不实现微信、个人微信或其他渠道协议。 - 微信桥接器把正文放在严格的 `New WeChat message` / `Conversation:` / `Text:` 三行传输信封中;AgentBus listener 会在任务快照前只解开这一已知信封,把 `Text:` 同行值及后续行作为业务正文,并在帧没有显式 `conversation_id` 时使用信封中的 `Conversation:` 值。显式字段仍优先;近似、缺失字段或空正文的包装保持原文,不能通过忽略任意未知标签来绕过 Program parser 的失败关闭。 - 微信侧的 `[WeChat attachment: 文件名]` 只是一段传输占位文字,不代表控制面已经收到文件。若同一帧没有符合契约的 `payload.attachments[]`,listener 会在进入任务服务前失败关闭、保留原名单任务的等待状态,并返回“附件内容未传到平台”;不会把占位文字创建成新业务任务。附件元数据、HTTPS URL、DNS、大小或摘要校验失败时返回对应的安全摘要,仍不回显 URL、文件字节或名单内容。当前生产部署位于受信内网,入站附件 URL 可以使用内网域名、私网 IPv4/IPv6 或 localhost;因此 AgentBus 渠道和上游桥接器必须被视为受信输入边界。 -- AgentBus 入站消息会复用 `TaskService` 的任务/会话/解析队列,解析完成后通过同一 WebSocket 返回一次 `task.result`。组织级“全自动化”关闭时,手工与 AgentBus 新任务都要求管理员确认;开启后,两种来源的合法解析结果都自动进入 ERP 队列,不再按来源或创建、名单、安排、修改、取消/恢复、导出等业务类型保留人工例外。操作台在 EventSource 建连/重连、30 秒后台刷新以及页面重新可见或聚焦时重新读取数据库权威开关,避免后台变更后按钮仍显示旧值。缺资料、解析失败、歧义、插件校验失败或 ERP 回查不确定时仍会停止,不会绕过校验或重试不确定写入。 -- 单一部署范围可以维护多个“用户渠道”。渠道代表外部 AgentBus 用户身份,不等同于平台账号;管理员在独立根路径 `/channels` 的“AgentBus 渠道”目录中创建、停用、启用、轮换或删除渠道。删除会停止对应 listener、移除服务端保存的 key 和该渠道尚存的持久化回执;历史任务本体保留,其 `channel_id` 按数据库契约置空。每个渠道独立保存加密后的 AgentBus key,并建立独立 WebSocket listener;列表和日志都不会回显 key。`AGENTBUS_WS_URL`、重连策略和客户端类型仍是全局连接配置,`AGENTBUS_BOT_ADDRESS` 可作为渠道 bot address 的默认值。仍由完整旧环境变量托管的兼容渠道会自动重建,必须先移除环境配置并重启服务,才允许删除其数据库记录。 +- 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` 也只执行归档;普通用户和组长只能归档/恢复本人任务,管理员可以处理全部授权任务。任务、输入、事件、尝试、附件元数据与审计记录继续保留,物理清除必须等待单独批准的保留期限和不可逆清除设计。 - `/operations-dashboard` 是组长和管理员专用的只读业务操作看板。它支持从结果状态、操作人、上海业务日期和业务类型逐层穿透,并可在选定范围内查询姓名、完整初始/补充指令、业务结果、团号或订单号。列表和详情只回答“谁提交了什么指令、完成了什么结果”:详情返回操作人、业务类型、完整指令轮次、输入附件名称/行数和可读业务结果,不返回任务生命周期、解析/执行 JSON、技术阶段、错误码或产物地址。关键词查询先受日期、人员、业务和状态约束,单次解密匹配候选最多 2,000 条,超过时要求继续缩小范围。该路径不授予他人任务修改、ERP 执行、SSE、产物下载、账号维护或全局安全审计权限,并排除 AgentBus/system 任务。 -- 使用数据库渠道时设置 `AGENTBUS_ENABLED=true`;此模式不要求 `AGENTBUS_WS_TOKEN` 或 `AGENTBUS_BOT_ADDRESS`,但启用的渠道仍需要全局 `AGENTBUS_WS_URL`,并可在渠道上覆盖 bot address。保留旧环境变量配置时,服务会按需创建“默认 AgentBus 渠道”兼容旧单渠道部署;`AGENTBUS_ENABLED=auto` 仅由完整的旧环境连接字段自动启用。 +- 使用数据库渠道时设置 `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,其他任务留在服务端等待;已开始写入但结果不确定的任务会阻塞后续领取,直到人工回查收敛。 @@ -89,11 +89,13 @@ Auto 一旦发生 AI fallback,任务会永久绑定原 AI 会话。每次解 - `POST /api/tasks/:taskId/reparse` - `PUT /api/parser-decisions/:decisionId/review` -迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、密码更新时间、输入/附件操作者、任务归档和账号级幂等(历史 `must_change_password` 列仅保留兼容,当前流程不启用首次强制改密);迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision。原文、完整程序/AI 候选、名单 canonical 中间文本和人工说明使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。 +迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、密码更新时间、输入/附件操作者、任务归档和账号级幂等(历史 `must_change_password` 列仅保留兼容,当前流程不启用首次强制改密);迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision;迁移 `018_agentbus_account_workers` 增加 ERP 账号、渠道归属、任务执行归属、唯一在线 worker 与 ERP 身份核验字段。原文、完整程序/AI 候选、名单 canonical 中间文本和人工说明使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。 ## AgentBus Bot 接入 -将 onboarding 文档中的 WebSocket 地址、WebSocket Token、Bot Address 和 Worker Address 写入服务端受保护的环境文件。配置 `AGENTBUS_ENABLED=auto` 时,只要填写 AgentBus 连接字段,控制平面就会自动启动长期监听;保持这些字段为空则不启动监听。 +数据库渠道模式在服务端受保护的环境文件中配置全局 `AGENTBUS_WS_URL` 并设置 `AGENTBUS_ENABLED=true`。管理员先在 `/accounts` 为员工配置唯一 ERP 账号和允许的业务类型,再在 `/channels` 创建渠道、填写该渠道自己的 AgentBus key,并选择对应员工账号。员工随后在自己的云电脑同时登录该平台账号与所绑定 ERP 账号并打开扩展。只有这四项就绪的启用渠道才启动长期 listener。 + +旧单渠道环境变量仍可创建一个兼容渠道记录,但该记录初始未绑定、停用且不启动;管理员必须完成员工绑定后手动启用。`AGENTBUS_ENABLED=auto` 只用于识别完整的旧环境连接字段,不会绕过账号绑定。 每个启用渠道会连接 `AGENTBUS_WS_URL?ready=1`,使用该渠道自己的 `Authorization: Bearer `,等待 `session.ready` 后接收普通 `event` 消息。普通任务发送一次持久化受理通知(`task.progress`,`status=accepted`)并在完成时返回一次 `task.result`。名单任务的首次文字指令改为返回明确的等待附件提示,附件入站改为返回“名单附件已收到,正在校验并处理”;最终结果只归属触发解析的最新附件消息,因此不会因文字与附件两条入站帧重复发送成功回执。解析完成、等待确认或进入 ERP 等内部进度不外发。`GET /health/ready` 和 `GET /api/status` 的 `agentbus.channels` 字段可用于确认每个 listener 与 session 是否建立。 @@ -139,7 +141,7 @@ npm run data:retention npm run dev ``` -`npm run dev` 和 `npm start` 会先执行数据库迁移,再启动控制平面;直接运行 `control-plane/src/server.ts` 或构建后的 `server.js` 时,服务也会在启动前检查必需迁移 `017_user_business_route_authorizations`,缺失时拒绝监听端口。`db:migrate` 和管理员初始化需要可连接的 PostgreSQL。开发机没有数据库时,可以运行 `npm run test:control-plane` 完成无数据库静态/健康烟测。 +`npm run dev` 和 `npm start` 会先执行数据库迁移,再启动控制平面;直接运行 `control-plane/src/server.ts` 或构建后的 `server.js` 时,服务也会在启动前检查必需迁移 `018_agentbus_account_workers`,缺失时拒绝监听端口。`db:migrate` 和管理员初始化需要可连接的 PostgreSQL。开发机没有数据库时,可以运行 `npm run test:control-plane` 完成无数据库静态/健康烟测。 `/health/ready` 同时检查 PostgreSQL 可用性和必需 schema 版本;迁移未完成时返回 503,并标明 `required_migration`,避免任务在数据库结构未升级时进入解析队列。 diff --git a/control-plane/migrations/018_agentbus_account_workers.sql b/control-plane/migrations/018_agentbus_account_workers.sql new file mode 100644 index 0000000..e299f02 --- /dev/null +++ b/control-plane/migrations/018_agentbus_account_workers.sql @@ -0,0 +1,100 @@ +-- Bind each employee-facing AgentBus channel and ERP browser worker to one +-- platform account. Historical AgentBus work remains deliberately unassigned: +-- ownership cannot be inferred safely from an old channel key. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS erp_account text; + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_erp_account_length_check; + +ALTER TABLE users + ADD CONSTRAINT users_erp_account_length_check + CHECK (erp_account IS NULL OR char_length(erp_account) BETWEEN 1 AND 200); + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_admin_erp_account_check; + +ALTER TABLE users + ADD CONSTRAINT users_admin_erp_account_check + CHECK (role <> 'admin' OR erp_account IS NULL); + +CREATE UNIQUE INDEX IF NOT EXISTS users_org_erp_account_unique_idx + ON users (organization_id, lower(erp_account)) + WHERE erp_account IS NOT NULL; + +ALTER TABLE user_channels + ADD COLUMN IF NOT EXISTS owner_user_id uuid; + +ALTER TABLE user_channels + DROP CONSTRAINT IF EXISTS user_channels_owner_scope_fkey; + +ALTER TABLE user_channels + ADD CONSTRAINT user_channels_owner_scope_fkey + FOREIGN KEY (organization_id, owner_user_id) + REFERENCES users (organization_id, id); + +CREATE UNIQUE INDEX IF NOT EXISTS user_channels_owner_unique_idx + ON user_channels (organization_id, owner_user_id) + WHERE owner_user_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS user_channels_owner_status_idx + ON user_channels (organization_id, owner_user_id, enabled, status); + +UPDATE user_channels + SET enabled = false, + status = 'error', + last_error = '渠道尚未绑定员工平台账号,监听器不会启动。', + updated_at = now() + WHERE owner_user_id IS NULL + AND enabled = true; + +ALTER TABLE tasks + ADD COLUMN IF NOT EXISTS assigned_user_id uuid; + +ALTER TABLE tasks + DROP CONSTRAINT IF EXISTS tasks_assignee_scope_fkey; + +ALTER TABLE tasks + ADD CONSTRAINT tasks_assignee_scope_fkey + FOREIGN KEY (organization_id, assigned_user_id) + REFERENCES users (organization_id, id); + +UPDATE tasks + SET assigned_user_id = created_by + WHERE assigned_user_id IS NULL + AND source = 'manual' + AND created_by IS NOT NULL; + +CREATE INDEX IF NOT EXISTS tasks_assignee_visible_created_idx + ON tasks (organization_id, assigned_user_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS tasks_assignee_queue_idx + ON tasks (organization_id, assigned_user_id, status, created_at, id) + WHERE archived_at IS NULL; + +ALTER TABLE browser_connections + ADD COLUMN IF NOT EXISTS erp_account_verified boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS erp_account_fingerprint text; + +WITH ranked_connections AS ( + SELECT id, + row_number() OVER ( + PARTITION BY organization_id, user_id + ORDER BY last_seen_at DESC, id DESC + ) AS worker_rank + FROM browser_connections + WHERE status = 'connected' +) +UPDATE browser_connections connection + SET status = 'superseded' + FROM ranked_connections ranked + WHERE connection.id = ranked.id + AND ranked.worker_rank > 1; + +CREATE UNIQUE INDEX IF NOT EXISTS browser_connections_active_user_unique_idx + ON browser_connections (organization_id, user_id) + WHERE status = 'connected'; + +CREATE INDEX IF NOT EXISTS browser_connections_worker_freshness_idx + ON browser_connections (organization_id, user_id, status, last_seen_at DESC); diff --git a/control-plane/src/agentbus-channels.ts b/control-plane/src/agentbus-channels.ts index d3b2919..36701f8 100644 --- a/control-plane/src/agentbus-channels.ts +++ b/control-plane/src/agentbus-channels.ts @@ -1,4 +1,4 @@ -import { decryptText, encryptText } from './crypto.js'; +import { decryptText, encryptText, sha256Text } from './crypto.js'; import { getPool, withTransaction } from './db.js'; import type { AppConfig } from './config.js'; import { @@ -17,6 +17,11 @@ export interface PublicAgentBusChannel { display_name: string; external_user_ref: string | null; agentbus_bot_address: string | null; + owner_user_id: string | null; + owner_username: string | null; + owner_role: 'team_lead' | 'user' | null; + owner_erp_account: string | null; + routing_ready: boolean; enabled: boolean; status: 'disabled' | 'connecting' | 'connected' | 'error'; key_configured: true; @@ -35,6 +40,7 @@ export interface AgentBusChannelSecret extends PublicAgentBusChannel { export interface AgentBusChannelMutation { displayName: string; + ownerUserId: string; externalUserRef?: string; agentbusKey: string; botAddress?: string; @@ -43,6 +49,7 @@ export interface AgentBusChannelMutation { export interface AgentBusChannelUpdate { displayName?: string; + ownerUserId?: string; externalUserRef?: string; botAddress?: string; enabled?: boolean; @@ -66,6 +73,11 @@ function publicChannel( const status = text(row.status); const environmentManaged = text(row.external_user_ref) === LEGACY_CHANNEL_REF && legacyEnvironmentManaged; + const ownerRole = text(row.owner_role); + const routingReady = Boolean(text(row.owner_user_id)) + && booleanValue(row.owner_is_active) + && (ownerRole === 'team_lead' || ownerRole === 'user') + && Boolean(text(row.owner_erp_account)); return { id: text(row.id), organization_id: text(row.organization_id), @@ -73,14 +85,22 @@ function publicChannel( display_name: text(row.display_name), external_user_ref: text(row.external_user_ref) || null, agentbus_bot_address: text(row.agentbus_bot_address) || null, + owner_user_id: text(row.owner_user_id) || null, + owner_username: text(row.owner_username) || null, + owner_role: ownerRole === 'team_lead' || ownerRole === 'user' ? ownerRole : null, + owner_erp_account: text(row.owner_erp_account) || null, + routing_ready: routingReady, enabled: row.enabled === true || text(row.enabled) === 'true', - status: ['disabled', 'connecting', 'connected', 'error'].includes(status) + status: !routingReady && booleanValue(row.enabled) + ? 'error' + : ['disabled', 'connecting', 'connected', 'error'].includes(status) ? status as PublicAgentBusChannel['status'] : 'error', key_configured: true, deletable: !environmentManaged, last_connected_at: iso(row.last_connected_at), - last_error: text(row.last_error) || null, + last_error: text(row.last_error) + || (!routingReady && booleanValue(row.enabled) ? '渠道未绑定有效员工账号及 ERP 账号,监听器不会启动。' : null), created_at: new Date(String(row.created_at)).toISOString(), updated_at: new Date(String(row.updated_at)).toISOString() }; @@ -145,6 +165,69 @@ export class AgentBusChannelService { && Boolean(text(this.config.AGENTBUS_BOT_ADDRESS)); } + private async requireAssignableOwner( + client: import('pg').PoolClient, + organizationId: string, + ownerUserId: string + ): Promise> { + const owner = await client.query( + `SELECT id, username, role, is_active, erp_account + FROM users + WHERE organization_id = $1 AND id = $2 + FOR SHARE`, + [organizationId, ownerUserId] + ); + if (!owner.rowCount) throw new TaskError('channel_owner_not_found', '要绑定的平台账号不存在。', 404); + const row = owner.rows[0] as Record; + if (!booleanValue(row.is_active)) { + throw new TaskError('channel_owner_inactive', '停用的平台账号不能绑定 AgentBus 渠道。', 409); + } + if (!['team_lead', 'user'].includes(text(row.role))) { + throw new TaskError('channel_owner_admin_forbidden', '管理员账号不能绑定员工 AgentBus 渠道。', 409); + } + if (!text(row.erp_account)) { + throw new TaskError('channel_owner_erp_account_required', '请先为员工平台账号配置 ERP 账号。', 409); + } + return row; + } + + private async requireUniqueAgentBusKey( + client: import('pg').PoolClient, + organizationId: string, + agentbusKey: string, + exceptChannelId = '' + ): Promise { + const candidateFingerprint = sha256Text(agentbusKey); + await client.query( + `SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':agentbus-key:' || $2::text, 0))`, + [organizationId, candidateFingerprint] + ); + const existing = await client.query( + `SELECT id, agentbus_ws_token_ciphertext + FROM user_channels + WHERE organization_id = $1 + AND ($2::uuid IS NULL OR id <> $2::uuid) + FOR SHARE`, + [organizationId, exceptChannelId || null] + ); + for (const row of existing.rows as Record[]) { + try { + const existingKey = decryptText(this.config, text(row.agentbus_ws_token_ciphertext)); + if (existingKey && sha256Text(existingKey) === candidateFingerprint) { + throw new TaskError( + 'channel_key_conflict', + '该 AgentBus key 已用于另一个用户渠道;每个渠道必须使用独立 key。', + 409 + ); + } + } catch (error) { + if (error instanceof TaskError) throw error; + // A separately corrupted historical key is handled by listener + // loading and must not disclose or block comparison of valid keys. + } + } + } + async list(organizationId: string): Promise { const result = await getPool(this.config).query( `WITH canonical_legacy AS ( @@ -155,9 +238,12 @@ export class AgentBusChannelService { ) SELECT channel.id, channel.organization_id, channel.display_name, channel.external_user_ref, channel.agentbus_bot_address, channel.enabled, channel.status, channel.last_connected_at, - channel.last_error, channel.created_at, channel.updated_at + channel.last_error, channel.created_at, channel.updated_at, + channel.owner_user_id, owner.username AS owner_username, owner.role AS owner_role, + owner.is_active AS owner_is_active, owner.erp_account AS owner_erp_account FROM user_channels channel LEFT JOIN canonical_legacy legacy ON legacy.id = channel.id + LEFT JOIN users owner ON owner.id = channel.owner_user_id WHERE channel.organization_id = $1 AND (channel.external_user_ref IS DISTINCT FROM '${LEGACY_CHANNEL_REF}' OR legacy.id IS NOT NULL) ORDER BY channel.created_at ASC, channel.id ASC`, @@ -178,9 +264,16 @@ export class AgentBusChannelService { ) SELECT channel.id, channel.organization_id, channel.display_name, channel.external_user_ref, channel.agentbus_bot_address, channel.enabled, channel.status, channel.last_connected_at, - channel.last_error, channel.created_at, channel.updated_at, channel.agentbus_ws_token_ciphertext + channel.last_error, channel.created_at, channel.updated_at, channel.agentbus_ws_token_ciphertext, + channel.owner_user_id, owner.username AS owner_username, owner.role AS owner_role, + owner.is_active AS owner_is_active, owner.erp_account AS owner_erp_account FROM user_channels channel LEFT JOIN canonical_legacy legacy ON legacy.id = channel.id + JOIN users owner ON owner.id = channel.owner_user_id + AND owner.organization_id = channel.organization_id + AND owner.is_active = true + AND owner.role IN ('team_lead', 'user') + AND owner.erp_account IS NOT NULL WHERE channel.organization_id = $1 AND channel.enabled = true AND (channel.external_user_ref IS DISTINCT FROM '${LEGACY_CHANNEL_REF}' OR legacy.id IS NOT NULL) @@ -234,18 +327,19 @@ export class AgentBusChannelService { if (!agentbusKey) throw new TaskError('channel_key_required', 'AgentBus key 不能为空。', 400); try { const result = await withTransaction(this.config, async (client) => { + const owner = await this.requireAssignableOwner(client, context.organizationId, text(input.ownerUserId)); + await this.requireUniqueAgentBusKey(client, context.organizationId, agentbusKey); const inserted = await client.query( `INSERT INTO user_channels - (organization_id, display_name, external_user_ref, + (organization_id, display_name, owner_user_id, external_user_ref, agentbus_ws_token_ciphertext, agentbus_bot_address, enabled, status, created_by) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, organization_id, display_name, external_user_ref, - agentbus_bot_address, enabled, status, last_connected_at, - last_error, created_at, updated_at`, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id`, [ context.organizationId, displayName, + text(owner.id), text(input.externalUserRef).slice(0, 200) || null, encryptText(this.config, agentbusKey), text(input.botAddress).slice(0, 200) || null, @@ -256,14 +350,18 @@ export class AgentBusChannelService { ); await this.audit(client, context, 'agentbus_channel.created', text(inserted.rows[0].id), { display_name: displayName, + owner_user_id: text(owner.id), external_user_ref_present: Boolean(text(input.externalUserRef)), enabled: input.enabled !== false }); - return inserted.rows[0] as Record; + return text(inserted.rows[0].id); }); - return publicChannel(result, this.legacyEnvironmentManaged()); + return publicChannel(await this.getRow(context.organizationId, result), this.legacyEnvironmentManaged()); } catch (error) { if (error && typeof error === 'object' && String((error as { code?: unknown }).code || '') === '23505') { + if (String((error as { constraint?: unknown }).constraint || '').includes('owner')) { + throw new TaskError('channel_owner_conflict', '该平台账号已经绑定另一个 AgentBus 渠道。', 409); + } throw new TaskError('channel_name_conflict', '同一组织下的渠道名称已存在。', 409); } throw error; @@ -289,21 +387,42 @@ export class AgentBusChannelService { : input.enabled; try { const result = await withTransaction(this.config, async (client) => { + const ownerUserId = input.ownerUserId === undefined + ? text(current.owner_user_id) + : text(input.ownerUserId); + const owner = await this.requireAssignableOwner(client, context.organizationId, ownerUserId); + if (enabled) { + const keyResult = await client.query( + `SELECT agentbus_ws_token_ciphertext + FROM user_channels + WHERE organization_id = $1 AND id = $2 + FOR SHARE`, + [context.organizationId, channelId] + ); + try { + const currentKey = decryptText(this.config, text(keyResult.rows[0]?.agentbus_ws_token_ciphertext)); + if (!currentKey) throw new Error('empty AgentBus key'); + await this.requireUniqueAgentBusKey(client, context.organizationId, currentKey, channelId); + } catch (error) { + if (error instanceof TaskError) throw error; + throw new TaskError('channel_key_invalid', '渠道 key 无法解密;请先轮换 key。', 409); + } + } const updated = await client.query( `UPDATE user_channels SET display_name = $1, - external_user_ref = $2, - agentbus_bot_address = $3, - enabled = $4, - status = CASE WHEN $4 THEN 'connecting' ELSE 'disabled' END, - last_error = CASE WHEN $4 THEN NULL ELSE last_error END, + owner_user_id = $2, + external_user_ref = $3, + agentbus_bot_address = $4, + enabled = $5, + status = CASE WHEN $5 THEN 'connecting' ELSE 'disabled' END, + last_error = CASE WHEN $5 THEN NULL ELSE last_error END, updated_at = now() - WHERE organization_id = $5 AND id = $6 - RETURNING id, organization_id, display_name, external_user_ref, - agentbus_bot_address, enabled, status, last_connected_at, - last_error, created_at, updated_at`, + WHERE organization_id = $6 AND id = $7 + RETURNING id`, [ displayName, + text(owner.id), externalUserRef, input.botAddress === undefined ? (text(current.agentbus_bot_address) || null) @@ -316,13 +435,17 @@ export class AgentBusChannelService { if (!updated.rowCount) throw new TaskError('channel_not_found', '用户渠道不存在。', 404); await this.audit(client, context, 'agentbus_channel.updated', channelId, { enabled, - display_name: displayName + display_name: displayName, + owner_user_id: text(owner.id) }); - return updated.rows[0] as Record; + return text(updated.rows[0].id); }); - return publicChannel(result, this.legacyEnvironmentManaged()); + return publicChannel(await this.getRow(context.organizationId, result), this.legacyEnvironmentManaged()); } catch (error) { if (error && typeof error === 'object' && String((error as { code?: unknown }).code || '') === '23505') { + if (String((error as { constraint?: unknown }).constraint || '').includes('owner')) { + throw new TaskError('channel_owner_conflict', '该平台账号已经绑定另一个 AgentBus 渠道。', 409); + } throw new TaskError('channel_name_conflict', '同一组织下的渠道名称已存在。', 409); } throw error; @@ -333,6 +456,7 @@ export class AgentBusChannelService { const key = text(agentbusKey); if (!key) throw new TaskError('channel_key_required', 'AgentBus key 不能为空。', 400); const result = await withTransaction(this.config, async (client) => { + await this.requireUniqueAgentBusKey(client, context.organizationId, key, channelId); const updated = await client.query( `UPDATE user_channels SET agentbus_ws_token_ciphertext = $1, @@ -340,16 +464,14 @@ export class AgentBusChannelService { last_error = NULL, updated_at = now() WHERE organization_id = $2 AND id = $3 - RETURNING id, organization_id, display_name, external_user_ref, - agentbus_bot_address, enabled, status, last_connected_at, - last_error, created_at, updated_at`, + RETURNING id`, [encryptText(this.config, key), context.organizationId, channelId] ); if (!updated.rowCount) throw new TaskError('channel_not_found', '用户渠道不存在。', 404); await this.audit(client, context, 'agentbus_channel.key_rotated', channelId, {}); - return updated.rows[0] as Record; + return text(updated.rows[0].id); }); - return publicChannel(result, this.legacyEnvironmentManaged()); + return publicChannel(await this.getRow(context.organizationId, result), this.legacyEnvironmentManaged()); } async delete( @@ -426,8 +548,9 @@ export class AgentBusChannelService { await client.query( `INSERT INTO user_channels (organization_id, display_name, external_user_ref, - agentbus_ws_token_ciphertext, agentbus_bot_address, enabled, status) - SELECT $1, '默认 AgentBus 渠道', '${LEGACY_CHANNEL_REF}', $2, $3, true, 'connecting' + agentbus_ws_token_ciphertext, agentbus_bot_address, enabled, status, last_error) + SELECT $1, '默认 AgentBus 渠道', '${LEGACY_CHANNEL_REF}', $2, $3, false, 'error', + '兼容渠道尚未绑定员工平台账号,监听器不会启动。' WHERE NOT EXISTS ( SELECT 1 FROM user_channels WHERE organization_id = $1 AND external_user_ref = '${LEGACY_CHANNEL_REF}' @@ -475,11 +598,14 @@ export class AgentBusChannelService { private async getRow(organizationId: string, channelId: string): Promise> { const result = await getPool(this.config).query( - `SELECT id, organization_id, display_name, external_user_ref, - agentbus_bot_address, enabled, status, last_connected_at, - last_error, created_at, updated_at - FROM user_channels - WHERE organization_id = $1 AND id = $2`, + `SELECT channel.id, channel.organization_id, channel.display_name, channel.external_user_ref, + channel.agentbus_bot_address, channel.enabled, channel.status, channel.last_connected_at, + channel.last_error, channel.created_at, channel.updated_at, + channel.owner_user_id, owner.username AS owner_username, owner.role AS owner_role, + owner.is_active AS owner_is_active, owner.erp_account AS owner_erp_account + FROM user_channels channel + LEFT JOIN users owner ON owner.id = channel.owner_user_id + WHERE channel.organization_id = $1 AND channel.id = $2`, [organizationId, channelId] ); if (!result.rowCount) throw new TaskError('channel_not_found', '用户渠道不存在。', 404); @@ -590,7 +716,9 @@ export class AgentBusManager { displayName: channel.display_name, wsUrl: channel.ws_url, wsToken: channel.ws_token, - botAddress: channel.bot_address + botAddress: channel.bot_address, + ownerUserId: channel.owner_user_id as string, + ownerRole: channel.owner_role as 'team_lead' | 'user' }, onStatusChange: (status, error, epoch) => this.channels.setRuntimeStatus( this.options.organizationId, diff --git a/control-plane/src/agentbus.ts b/control-plane/src/agentbus.ts index 65d35a7..422a18c 100644 --- a/control-plane/src/agentbus.ts +++ b/control-plane/src/agentbus.ts @@ -11,7 +11,8 @@ import type { TaskEvent, TaskMessageInput, TaskMessageResult, - AgentBusAttachmentContent + AgentBusAttachmentContent, + TaskRole } from './task-service.js'; import { InputAttachmentError, @@ -138,6 +139,8 @@ export interface AgentBusChannelConnection { wsUrl: string; wsToken: string; botAddress: string; + ownerUserId: string; + ownerRole: Exclude; } export interface AgentBusListenerOptions { @@ -1009,8 +1012,9 @@ export class AgentBusListener { }; const context: TaskContext = { organizationId: this.organizationId, - userId: '', + userId: this.channel?.ownerUserId || '', requestId: `agentbus:${taskId}`, + role: this.channel?.ownerRole, source: 'agentbus', ...(this.channel ? { channelId: this.channel.id } : {}) }; diff --git a/control-plane/src/auth.ts b/control-plane/src/auth.ts index 2636848..7123182 100644 --- a/control-plane/src/auth.ts +++ b/control-plane/src/auth.ts @@ -20,12 +20,14 @@ export interface AuthUser { organizationId: string; username: string; role: AuthRole; + erpAccount: string | null; } export interface PublicAccount { id: string; username: string; role: AuthRole; + erp_account: string | null; is_active: boolean; authorized_business_route_ids: BusinessRouteId[]; business_authorization_revision: number; @@ -78,6 +80,27 @@ function validatePassword(value: string): string { return password; } +function normalizeErpAccount(value: unknown): string | null { + const normalized = String(value ?? '').trim(); + if (!normalized) return null; + if (normalized.length > 200) { + throw new AuthError('erp_account_invalid', 'ERP 账号必须为 1—200 个字符。', 400); + } + return normalized; +} + +function validateAccountRouting(role: AuthRole, value: unknown): string | null { + const erpAccount = normalizeErpAccount(value); + if (role === 'admin') { + if (erpAccount) throw new AuthError('admin_erp_account_forbidden', '管理员账号不能绑定员工 ERP 账号。', 409); + return null; + } + if (!erpAccount) { + throw new AuthError('erp_account_required', '普通用户或组长必须绑定 ERP 账号。', 400); + } + return erpAccount; +} + function normalizeRole(value: unknown): AuthRole { if (value === 'admin' || value === 'team_lead') return value; return 'user'; @@ -109,7 +132,8 @@ function mapUser(row: Record): AuthUser { id: String(row.id), organizationId: String(row.organization_id), username: String(row.username), - role: normalizeRole(row.role) + role: normalizeRole(row.role), + erpAccount: normalizeErpAccount(row.erp_account) }; } @@ -122,6 +146,7 @@ function mapAccount(row: Record): PublicAccount { id: String(row.id), username: String(row.username), role, + erp_account: normalizeErpAccount(row.erp_account), is_active: row.is_active === true || String(row.is_active) === 'true', authorized_business_route_ids: role === 'admin' ? [...ALL_BUSINESS_ROUTE_IDS] : storedRouteIds, business_authorization_revision: Math.max(0, Number(row.business_authorization_revision || 0)), @@ -137,7 +162,7 @@ async function loadPublicAccount( userId: string ): Promise { const result = await client.query( - `SELECT u.id, u.username, u.role, u.is_active, + `SELECT u.id, u.username, u.role, u.erp_account, u.is_active, u.business_authorization_revision, u.last_login_at, u.created_at, u.updated_at, COALESCE(ARRAY( @@ -209,21 +234,34 @@ export class AuthService { const result = existing.rowCount ? await client.query( `UPDATE users - SET password_hash = $1, role = 'admin', is_active = true, + SET password_hash = $1, role = 'admin', erp_account = NULL, is_active = true, must_change_password = false, password_changed_at = now(), failed_login_count = 0, locked_until = NULL, updated_at = now() WHERE id = $2 - RETURNING id, organization_id, username, role`, + RETURNING id, organization_id, username, role, erp_account`, [passwordHash, existing.rows[0].id] ) : await client.query( `INSERT INTO users (organization_id, username, password_hash, role) VALUES ($1, $2, $3, 'admin') - RETURNING id, organization_id, username, role`, + RETURNING id, organization_id, username, role, erp_account`, [organization.id, normalized, passwordHash] ); const user = mapUser(result.rows[0]); if (existing.rowCount && force) { + await client.query( + `UPDATE user_channels + SET owner_user_id = NULL, enabled = false, status = 'disabled', + last_error = '绑定账号已重置为管理员,渠道已解除绑定。', updated_at = now() + WHERE organization_id = $1 AND owner_user_id = $2`, + [organization.id, user.id] + ); + await client.query( + `UPDATE browser_connections + SET status = 'superseded', erp_account_verified = false + WHERE organization_id = $1 AND user_id = $2 AND status = 'connected'`, + [organization.id, user.id] + ); await client.query( 'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL', [user.id] @@ -237,7 +275,7 @@ export class AuthService { const normalized = normalizeUsername(username); const pool = getPool(this.config); const lookup = await pool.query( - `SELECT id, organization_id, username, password_hash, role, is_active, + `SELECT id, organization_id, username, password_hash, role, erp_account, is_active, failed_login_count, locked_until FROM users WHERE organization_id = (SELECT id FROM organizations WHERE slug = $1) @@ -330,7 +368,7 @@ export class AuthService { async listAccounts(actor: AuthUser): Promise { this.requireAdmin(actor); const result = await getPool(this.config).query( - `SELECT u.id, u.username, u.role, u.is_active, + `SELECT u.id, u.username, u.role, u.erp_account, u.is_active, u.business_authorization_revision, u.last_login_at, u.created_at, u.updated_at, COALESCE(ARRAY( @@ -353,6 +391,7 @@ export class AuthService { username: string; password: string; role: AuthRole; + erpAccount?: string; businessRouteIds?: readonly string[]; }, requestId: string @@ -361,8 +400,10 @@ export class AuthService { const username = validateUsername(input.username); const passwordHash = await argon2.hash(validatePassword(input.password), { type: argon2.argon2id }); const role = normalizeRole(input.role); + const erpAccount = validateAccountRouting(role, input.erpAccount); const businessRouteIds = role === 'admin' ? [] : normalizeBusinessRouteIds(input.businessRouteIds); - return withTransaction(this.config, async (client) => { + try { + return await withTransaction(this.config, async (client) => { await client.query( `SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':' || $2::text, 0))`, [actor.organizationId, username] @@ -374,10 +415,10 @@ export class AuthService { if (existing.rowCount) throw new AuthError('account_exists', '该账号已存在。', 409); const created = await client.query( `INSERT INTO users - (organization_id, username, password_hash, role, password_changed_at) - VALUES ($1, $2, $3, $4, now()) + (organization_id, username, password_hash, role, erp_account, password_changed_at) + VALUES ($1, $2, $3, $4, $5, now()) RETURNING id`, - [actor.organizationId, username, passwordHash, role] + [actor.organizationId, username, passwordHash, role, erpAccount] ); const accountId = String(created.rows[0].id); if (businessRouteIds.length) { @@ -393,25 +434,36 @@ export class AuthService { if (!account) throw new AuthError('account_not_found', '账号创建后未能读取。', 500); await this.accountAudit(client, actor, 'account.created', account.id, requestId, { role, + erp_account_configured: Boolean(erpAccount), authorized_business_route_ids: account.authorized_business_route_ids }); - return account; - }); + return account; + }); + } catch (error) { + if (error && typeof error === 'object' && String((error as { code?: unknown }).code || '') === '23505') { + const constraint = String((error as { constraint?: unknown }).constraint || ''); + if (constraint.includes('erp_account')) { + throw new AuthError('erp_account_conflict', '该 ERP 账号已经绑定另一个平台账号。', 409); + } + } + throw error; + } } async updateAccount( actor: AuthUser, targetUserId: string, - input: { role?: AuthRole; isActive?: boolean }, + input: { role?: AuthRole; isActive?: boolean; erpAccount?: string | null }, requestId: string ): Promise { this.requireAdmin(actor); - if (input.role === undefined && input.isActive === undefined) { + if (input.role === undefined && input.isActive === undefined && input.erpAccount === undefined) { throw new AuthError('account_update_empty', '没有需要更新的账号字段。', 400); } - return withTransaction(this.config, async (client) => { + try { + return await withTransaction(this.config, async (client) => { const target = await client.query( - `SELECT id, username, role, is_active, + `SELECT id, username, role, erp_account, is_active, last_login_at, created_at, updated_at FROM users WHERE organization_id = $1 AND id = $2 @@ -422,6 +474,12 @@ export class AuthService { const before = mapAccount(target.rows[0] as Record); const role = input.role === undefined ? before.role : normalizeRole(input.role); const isActive = input.isActive === undefined ? before.is_active : input.isActive; + const erpAccount = validateAccountRouting( + role, + role === 'admin' + ? null + : input.erpAccount === undefined ? before.erp_account : input.erpAccount + ); const removesActiveAdmin = before.role === 'admin' && before.is_active && (role !== 'admin' || !isActive); if (actor.id === before.id && (role !== 'admin' || !isActive)) { throw new AuthError('self_lockout_forbidden', '不能停用或降级当前登录的管理员账号。', 409); @@ -439,28 +497,56 @@ export class AuthService { } const updated = await client.query( `UPDATE users - SET role = $1, is_active = $2, updated_at = now() - WHERE organization_id = $3 AND id = $4 + SET role = $1, is_active = $2, erp_account = $3, updated_at = now() + WHERE organization_id = $4 AND id = $5 RETURNING id`, - [role, isActive, actor.organizationId, before.id] + [role, isActive, erpAccount, actor.organizationId, before.id] ); - if (before.role !== role || before.is_active !== isActive) { + const routingIdentityChanged = before.erp_account !== erpAccount; + if (before.role !== role || before.is_active !== isActive || routingIdentityChanged) { await client.query( 'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL', [before.id] ); } + if (!isActive || role === 'admin') { + await client.query( + `UPDATE user_channels + SET owner_user_id = NULL, enabled = false, status = 'disabled', + last_error = $1, updated_at = now() + WHERE organization_id = $2 AND owner_user_id = $3`, + [role === 'admin' ? '绑定账号已变更为管理员,渠道已解除绑定。' : '绑定账号已停用,渠道已解除绑定。', actor.organizationId, before.id] + ); + } + if (!isActive || routingIdentityChanged || before.role !== role) { + await client.query( + `UPDATE browser_connections + SET status = 'superseded', erp_account_verified = false + WHERE organization_id = $1 AND user_id = $2 AND status = 'connected'`, + [actor.organizationId, before.id] + ); + } await this.accountAudit(client, actor, 'account.updated', before.id, requestId, { previous_role: before.role, role, previous_active: before.is_active, active: isActive, - sessions_revoked: before.role !== role || before.is_active !== isActive + erp_account_changed: routingIdentityChanged, + sessions_revoked: before.role !== role || before.is_active !== isActive || routingIdentityChanged }); const account = await loadPublicAccount(client, actor.organizationId, String(updated.rows[0].id)); if (!account) throw new AuthError('account_not_found', '账号更新后未能读取。', 500); - return account; - }); + return account; + }); + } catch (error) { + if (error && typeof error === 'object' && String((error as { code?: unknown }).code || '') === '23505') { + const constraint = String((error as { constraint?: unknown }).constraint || ''); + if (constraint.includes('erp_account')) { + throw new AuthError('erp_account_conflict', '该 ERP 账号已经绑定另一个平台账号。', 409); + } + } + throw error; + } } async setBusinessRouteAuthorizations( @@ -648,7 +734,7 @@ export class AuthService { async getActiveSession(token: string | undefined): Promise { if (!token) return null; const result = await getPool(this.config).query( - `SELECT s.id AS session_id, s.csrf_token_hash, u.id, u.organization_id, u.username, u.role + `SELECT s.id AS session_id, s.csrf_token_hash, u.id, u.organization_id, u.username, u.role, u.erp_account FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 @@ -671,7 +757,8 @@ export class AuthService { id: row.id, organization_id: row.organization_id, username: row.username, - role: row.role + role: row.role, + erp_account: row.erp_account }) }; } diff --git a/control-plane/src/db.ts b/control-plane/src/db.ts index f5d952e..fe94de2 100644 --- a/control-plane/src/db.ts +++ b/control-plane/src/db.ts @@ -5,7 +5,7 @@ import { writeEmergencyDiagnostic } from './diagnostics.js'; const { Pool } = pg; let pool: pg.Pool | null = null; -export const REQUIRED_SCHEMA_VERSION = '017_user_business_route_authorizations'; +export const REQUIRED_SCHEMA_VERSION = '018_agentbus_account_workers'; export interface DatabaseReadiness { ready: boolean; diff --git a/control-plane/src/server.ts b/control-plane/src/server.ts index 0297b73..947b194 100644 --- a/control-plane/src/server.ts +++ b/control-plane/src/server.ts @@ -63,6 +63,7 @@ const accountCreateSchema = z.object({ username: z.string().min(1).max(160), password: z.string().min(1), role: z.enum(['admin', 'team_lead', 'user']).default('user'), + erp_account: z.string().trim().max(200).optional(), business_route_ids: z.array( z.string().trim().refine((routeId) => Boolean(businessRouteById(routeId)), '业务类型不存在。') ).max(BUSINESS_ROUTES.length).default([]) @@ -71,8 +72,9 @@ const accountCreateSchema = z.object({ const accountUpdateSchema = z.object({ role: z.enum(['admin', 'team_lead', 'user']).optional(), - is_active: z.boolean().optional() -}).refine((body) => body.role !== undefined || body.is_active !== undefined, { + is_active: z.boolean().optional(), + erp_account: z.string().trim().max(200).nullable().optional() +}).refine((body) => body.role !== undefined || body.is_active !== undefined || body.erp_account !== undefined, { message: '至少提供一个账号更新字段。' }); @@ -123,6 +125,8 @@ const resultSchema = z.object({ const heartbeatSchema = z.object({ connection_id: z.string().min(1).max(200), extension_version: z.string().max(80).optional(), + erp_account: z.string().trim().max(200).optional(), + erp_account_matched: z.boolean().optional(), metadata: z.record(z.unknown()).optional() }); const automationSettingsSchema = z.object({ enabled: z.boolean() }); @@ -138,6 +142,7 @@ const parserDecisionReviewSchema = z.object({ }); const channelCreateSchema = z.object({ display_name: z.string().min(1).max(120), + owner_user_id: z.string().uuid(), external_user_ref: z.string().max(200).optional(), agentbus_key: z.string().min(1).max(4_000), bot_address: z.string().max(200).optional(), @@ -145,6 +150,7 @@ const channelCreateSchema = z.object({ }); const channelUpdateSchema = z.object({ display_name: z.string().min(1).max(120).optional(), + owner_user_id: z.string().uuid().optional(), external_user_ref: z.string().max(200).optional(), bot_address: z.string().max(200).optional(), enabled: z.boolean().optional() @@ -156,6 +162,7 @@ const listTasksQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(200).default(200), offset: z.coerce.number().int().min(0).max(1_000_000).default(0), archive: z.enum(['active', 'archived', 'all']).default('active'), + executable_by: z.enum(['me']).optional(), include_total: z.preprocess( (value) => value === undefined ? true : String(value).toLowerCase() !== 'false', z.boolean() @@ -361,7 +368,8 @@ function publicUser(session: ActiveSession) { return { id: session.user.id, username: session.user.username, - role: session.user.role + role: session.user.role, + erp_account: session.user.erpAccount }; } @@ -963,6 +971,7 @@ export async function buildServer({ username: body.username, password: body.password, role: body.role, + erpAccount: body.erp_account, businessRouteIds: body.business_route_ids }, requestId(request)); return { ok: true, account }; @@ -975,8 +984,10 @@ export async function buildServer({ const body = accountUpdateSchema.parse(request.body); const account = await auth.updateAccount(session.user, userId, { role: body.role, - isActive: body.is_active + isActive: body.is_active, + erpAccount: body.erp_account }, requestId(request)); + await agentBus?.reload(); return { ok: true, account }; }); @@ -1098,6 +1109,7 @@ export async function buildServer({ const body = channelCreateSchema.parse(request.body); const channel = await channelService.create(contextFor(session, request), { displayName: body.display_name, + ownerUserId: body.owner_user_id, externalUserRef: body.external_user_ref, agentbusKey: body.agentbus_key, botAddress: body.bot_address, @@ -1113,6 +1125,7 @@ export async function buildServer({ const body = channelUpdateSchema.parse(request.body); const channel = await channelService.update(contextFor(session, request), params.channelId, { displayName: body.display_name, + ownerUserId: body.owner_user_id, externalUserRef: body.external_user_ref, botAddress: body.bot_address, enabled: body.enabled @@ -1162,6 +1175,7 @@ export async function buildServer({ offset: query.offset, includeTotal: query.include_total, archive: query.archive, + assignedToUserId: query.executable_by === 'me' ? session.user.id : undefined, access: contextFor(session, request) }); return { @@ -1334,8 +1348,17 @@ export async function buildServer({ app.post('/api/connections/heartbeat', async (request) => { const session = await requireMutationSession(request); const body = heartbeatSchema.parse(request.body); - await tasks.heartbeat(contextFor(session, request), body.connection_id, body.extension_version || '', body.metadata || {}); - return { ok: true, connected: true }; + const worker = await tasks.heartbeat( + contextFor(session, request), + body.connection_id, + body.extension_version || '', + body.metadata || {}, + { + erpAccount: body.erp_account || '', + erpAccountMatched: body.erp_account_matched === true + } + ); + return { ok: true, connected: true, ...worker }; }); app.get('/api/audit', async (request) => { @@ -1417,8 +1440,7 @@ export async function buildServer({ const send = (event: TaskEvent) => { if (event.organization_id !== session.user.organizationId) return; if (!canAccessTask(contextFor(session, request), { - createdBy: event.owner_user_id, - source: event.task_source || 'agentbus' + assignedUserId: event.owner_user_id })) return; const publicEvent = { id: event.id, diff --git a/control-plane/src/task-service.ts b/control-plane/src/task-service.ts index 6e20de7..f7ed34c 100644 --- a/control-plane/src/task-service.ts +++ b/control-plane/src/task-service.ts @@ -345,6 +345,7 @@ export interface PublicTask { confirmation_mode: ConfirmationMode; confirmed_at: string | null; creator?: PublicTaskActor | null; + assignee?: PublicTaskActor | null; archived_at?: string | null; archived_by?: PublicTaskActor | null; archive_reason?: string | null; @@ -382,6 +383,7 @@ export interface PublicTaskSummary { confirmation_mode: ConfirmationMode; confirmed_at: string | null; creator?: PublicTaskActor | null; + assignee?: PublicTaskActor | null; archived_at?: string | null; archived_by?: PublicTaskActor | null; archive_reason?: string | null; @@ -547,12 +549,11 @@ export function canViewOperationsDashboard(role: TaskRole | undefined): boolean export function canAccessTask( access: TaskAccessScope, - task: { createdBy: string | null | undefined; source: TaskSource } + task: { assignedUserId: string | null | undefined } ): boolean { if (!isTaskOwnerRestricted(access.role)) return true; return Boolean(access.userId) - && task.createdBy === access.userId - && task.source === 'manual'; + && task.assignedUserId === access.userId; } export function canExecuteBusinessRoute({ @@ -566,8 +567,7 @@ export function canExecuteBusinessRoute({ routeId: BusinessRouteId | null; authorizedRouteIds: readonly BusinessRouteId[]; }): boolean { - if (source !== 'manual') return true; - if (role === 'admin') return true; + if (role === 'admin') return source === 'manual'; if (!isTaskOwnerRestricted(role) || !routeId) return false; return authorizedRouteIds.includes(routeId); } @@ -2502,16 +2502,6 @@ export class TaskService { ): Promise { const source = normalizeTaskSource(context.source); const route = businessRouteById(routeId); - if (source !== 'manual') { - return { - allowed: true, - code: '', - message: '', - routeId: route?.routeId || null, - directive: route?.directive || null, - authorizationRevision: 0 - }; - } const user = await client.query( `SELECT u.role, u.is_active, u.business_authorization_revision, EXISTS ( @@ -2618,7 +2608,7 @@ export class TaskService { private taskAuthorizationContext(row: Record, requestId: string): TaskContext { return { organizationId: text(row.organization_id), - userId: text(row.created_by), + userId: text(row.assigned_user_id), requestId, source: normalizeTaskSource(row.source) }; @@ -2637,8 +2627,8 @@ export class TaskService { if (!decision.allowed) { const base = this.businessAuthorizationError(decision); const message = decision.directive - ? `该任务的创建账号未授权“${decision.directive}”业务,已禁止执行。请先由管理员重新授权。` - : '该任务的创建账号无法获得明确任务类型授权,已禁止执行。'; + ? `该任务的归属账号未授权“${decision.directive}”业务,已禁止执行。请先由管理员重新授权。` + : '该任务的归属账号无法获得明确任务类型授权,已禁止执行。'; throw new TaskError(base.code, message, base.statusCode, base.details); } return decision; @@ -2673,7 +2663,7 @@ export class TaskService { `SELECT * FROM tasks WHERE organization_id = $1 AND task_id = $2 - AND ($3::boolean = false OR (created_by = $4 AND source = 'manual')) + AND ($3::boolean = false OR assigned_user_id = $4) FOR UPDATE`, [context.organizationId, taskId, this.isOwnerRestrictedUser(context), context.userId || null] ); @@ -2691,14 +2681,35 @@ export class TaskService { connectionId: string ): Promise { const connection = await client.query( - `SELECT id FROM browser_connections - WHERE organization_id = $1 AND user_id = $2 AND connection_id = $3 - FOR UPDATE`, + `SELECT connection.id, connection.status, connection.last_seen_at, + connection.erp_account_verified, connection.erp_account_fingerprint, + account.role, account.erp_account + FROM browser_connections connection + JOIN users account ON account.id = connection.user_id + WHERE connection.organization_id = $1 + AND connection.user_id = $2 + AND connection.connection_id = $3 + FOR UPDATE OF connection, account`, [context.organizationId, context.userId, connectionId] ); if (!connection.rowCount) { throw new TaskError('browser_connection_not_owned', '浏览器连接不存在或不属于当前账号,请重新连接。', 403); } + const row = connection.rows[0] as Record; + const lastSeenAt = Date.parse(text(row.last_seen_at)); + if (text(row.status) !== 'connected' || !Number.isFinite(lastSeenAt) || Date.now() - lastSeenAt > 90_000) { + throw new TaskError('browser_worker_stale', '当前云电脑执行连接已过期,请等待插件重新连接。', 409); + } + const expectedErpAccount = text(row.erp_account); + if (text(row.role) !== 'admin') { + if (!expectedErpAccount) { + throw new TaskError('erp_account_not_configured', '当前平台账号尚未配置 ERP 账号,禁止执行。', 409); + } + const expectedFingerprint = sha256Text(expectedErpAccount.toLocaleLowerCase()); + if (!databaseBoolean(row.erp_account_verified) || text(row.erp_account_fingerprint) !== expectedFingerprint) { + throw new TaskError('erp_account_mismatch', '当前云电脑登录的 ERP 账号与平台绑定账号不一致,禁止执行。', 409); + } + } } private log( @@ -2766,6 +2777,7 @@ export class TaskService { confirmation_mode: text(row.confirmation_mode) === 'automatic' ? 'automatic' : 'manual', confirmed_at: row.confirmed_at ? new Date(String(row.confirmed_at)).toISOString() : null, creator: publicActor(row.creator_id, row.creator_username), + assignee: publicActor(row.assignee_id, row.assignee_username), archived_at: row.archived_at ? new Date(String(row.archived_at)).toISOString() : null, archived_by: publicActor(row.archived_by_id, row.archived_by_username), archive_reason: text(row.archive_reason) || null, @@ -2822,6 +2834,7 @@ export class TaskService { confirmation_mode: text(row.confirmation_mode) === 'automatic' ? 'automatic' : 'manual', confirmed_at: row.confirmed_at ? new Date(String(row.confirmed_at)).toISOString() : null, creator: publicActor(row.creator_id, row.creator_username), + assignee: publicActor(row.assignee_id, row.assignee_username), archived_at: row.archived_at ? new Date(String(row.archived_at)).toISOString() : null, archived_by: publicActor(row.archived_by_id, row.archived_by_username), archive_reason: text(row.archive_reason) || null, @@ -2894,7 +2907,7 @@ export class TaskService { message: text(eventRow.message), payload: jsonObject(eventRow.payload), created_at: new Date(String(eventRow.created_at)).toISOString(), - owner_user_id: text(row.created_by) || null, + owner_user_id: text(row.assigned_user_id) || null, task_source: normalizeTaskSource(row.source) }; } @@ -3542,7 +3555,7 @@ export class TaskService { JOIN agent_sessions s ON s.task_id = t.id WHERE t.organization_id = $1 AND t.task_id = $2 AND t.archived_at IS NULL - AND ($3::boolean = false OR (t.created_by = $4 AND t.source = 'manual'))`, + AND ($3::boolean = false OR t.assigned_user_id = $4)`, [context.organizationId, taskId, this.isOwnerRestrictedUser(context), context.userId || null] ); if (!result.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); @@ -3560,7 +3573,7 @@ export class TaskService { AND t.archived_at IS NULL AND s.conversation_id = $2 AND ($3::uuid IS NULL OR t.channel_id = $3::uuid) - AND ($4::boolean = false OR (t.created_by = $5 AND t.source = 'manual')) + AND ($4::boolean = false OR t.assigned_user_id = $5) ORDER BY t.updated_at DESC`, [ context.organizationId, @@ -3686,7 +3699,7 @@ export class TaskService { JOIN agent_sessions s ON s.task_id = t.id WHERE t.organization_id = $1 AND t.id = $2 AND t.archived_at IS NULL - AND ($3::boolean = false OR (t.created_by = $4 AND t.source = 'manual')) + AND ($3::boolean = false OR t.assigned_user_id = $4) FOR UPDATE OF t, s`, [context.organizationId, target.rowId, this.isOwnerRestrictedUser(context), context.userId || null] ); @@ -3724,7 +3737,7 @@ export class TaskService { AND i.scope = 'agent_message' AND i.idempotency_key = $2 AND i.actor_user_id IS NOT DISTINCT FROM $3::uuid - AND ($4::boolean = false OR (t.created_by = $5 AND t.source = 'manual')) + AND ($4::boolean = false OR t.assigned_user_id = $5) FOR UPDATE`, [ context.organizationId, @@ -4023,7 +4036,7 @@ export class TaskService { JOIN tasks t ON t.id = i.task_id WHERE i.organization_id = $1 AND i.scope = 'create_task' AND i.idempotency_key = $2 AND i.actor_user_id IS NOT DISTINCT FROM $3::uuid - AND ($4::boolean = false OR (t.created_by = $5 AND t.source = 'manual')) + AND ($4::boolean = false OR t.assigned_user_id = $5) FOR UPDATE`, [ context.organizationId, @@ -4055,16 +4068,16 @@ export class TaskService { `INSERT INTO tasks (organization_id, task_id, source, original_text_ciphertext, business_route_id, input_contract_version, parser_mode, parser_config_revision, parser_engine_affinity, - status, stage, message, created_by, idempotency_key) + status, stage, message, created_by, assigned_user_id, idempotency_key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14) + $10, $11, $12, $13, $14, $15) RETURNING *`, [ context.organizationId, taskId, source, encryptText(this.config, normalized), parserSnapshot.routeId, parserSnapshot.inputContractVersion, parserSnapshot.mode, parserSnapshot.revision, parserSnapshot.engineAffinity, intake.status, intake.stage, intake.message, - context.userId || null, idempotencyKey || null + context.userId || null, context.userId || null, idempotencyKey || null ] ); const row = inserted.rows[0] as Record; @@ -4182,7 +4195,7 @@ export class TaskService { FROM tasks WHERE organization_id = $1 AND task_id = $2 AND archived_at IS NULL - AND ($3::boolean = false OR (created_by = $4 AND source = 'manual'))`, + AND ($3::boolean = false OR assigned_user_id = $4)`, [context.organizationId, requestedTaskId, this.isOwnerRestrictedUser(context), context.userId || null] ); if (target.rowCount) preflightRouteId = businessRouteById(target.rows[0].business_route_id)?.routeId || null; @@ -4196,7 +4209,7 @@ export class TaskService { AND t.archived_at IS NULL AND s.conversation_id = $2 AND ($3::uuid IS NULL OR t.channel_id = $3::uuid) - AND ($4::boolean = false OR (t.created_by = $5 AND t.source = 'manual')) + AND ($4::boolean = false OR t.assigned_user_id = $5) ORDER BY t.updated_at DESC`, [ context.organizationId, @@ -4220,12 +4233,15 @@ export class TaskService { const outcome = await withTransaction(this.config, async (client) => { if (channelId) { const channel = await client.query( - `SELECT id FROM user_channels - WHERE organization_id = $1 AND id = $2 + `SELECT id, owner_user_id FROM user_channels + WHERE organization_id = $1 AND id = $2 AND enabled = true FOR SHARE`, [context.organizationId, channelId] ); if (!channel.rowCount) throw new TaskError('channel_not_found', '用户渠道不存在。', 404); + if (!context.userId || text(channel.rows[0].owner_user_id) !== context.userId) { + throw new TaskError('channel_owner_mismatch', 'AgentBus 渠道与任务归属账号不一致,已拒绝接收。', 403); + } } if (idempotencyKey) { await this.lockIdempotencyKey( @@ -4254,7 +4270,7 @@ export class TaskService { `SELECT id, task_id, channel_id FROM tasks WHERE organization_id = $1 AND task_id = $2 - AND ($3::boolean = false OR (created_by = $4 AND source = 'manual')) + AND ($3::boolean = false OR assigned_user_id = $4) FOR UPDATE`, [ context.organizationId, @@ -4297,7 +4313,7 @@ export class TaskService { JOIN agent_sessions s ON s.task_id = t.id WHERE t.organization_id = $1 AND t.task_id = $2 AND t.archived_at IS NULL - AND ($3::boolean = false OR (t.created_by = $4 AND t.source = 'manual')) + AND ($3::boolean = false OR t.assigned_user_id = $4) FOR UPDATE OF t, s`, [context.organizationId, requestedTaskId, this.isOwnerRestrictedUser(context), context.userId || null] ); @@ -4332,7 +4348,7 @@ export class TaskService { AND t.archived_at IS NULL AND s.conversation_id = $2 AND ($3::uuid IS NULL OR t.channel_id = $3::uuid) - AND ($4::boolean = false OR (t.created_by = $5 AND t.source = 'manual')) + AND ($4::boolean = false OR t.assigned_user_id = $5) ORDER BY t.updated_at DESC FOR UPDATE OF t, s`, [ @@ -4367,15 +4383,15 @@ export class TaskService { `INSERT INTO tasks (organization_id, task_id, source, channel_id, original_text_ciphertext, business_route_id, input_contract_version, parser_mode, parser_config_revision, parser_engine_affinity, - status, stage, message, created_by) + status, stage, message, created_by, assigned_user_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, - $11, $12, $13, $14) + $11, $12, $13, $14, $15) RETURNING *`, [ context.organizationId, taskId, source, channelId || null, encryptText(this.config, normalizedMessage), parserSnapshot.routeId, parserSnapshot.inputContractVersion, parserSnapshot.mode, parserSnapshot.revision, parserSnapshot.engineAffinity, - intake.status, intake.stage, intake.message, context.userId || null + intake.status, intake.stage, intake.message, context.userId || null, context.userId || null ] ); const taskRow = inserted.rows[0] as Record; @@ -4535,6 +4551,7 @@ export class TaskService { const result = await getPool(this.config).query( `SELECT t.*, uc.display_name AS channel_name, s.conversation_id, creator.id AS creator_id, creator.username AS creator_username, + assignee.id AS assignee_id, assignee.username AS assignee_username, archiver.id AS archived_by_id, archiver.username AS archived_by_username, s.status AS agent_session_status, s.current_turn AS agent_session_turn, @@ -4555,6 +4572,7 @@ export class TaskService { LEFT JOIN user_channels uc ON uc.id = t.channel_id LEFT JOIN agent_sessions s ON s.task_id = t.id LEFT JOIN users creator ON creator.id = t.created_by + LEFT JOIN users assignee ON assignee.id = t.assigned_user_id LEFT JOIN users archiver ON archiver.id = t.archived_by LEFT JOIN LATERAL ( SELECT id, review_status, diff_summary, program_result_ciphertext, ai_result_ciphertext @@ -4564,7 +4582,7 @@ export class TaskService { LIMIT 1 ) pd ON true WHERE t.organization_id = $1 AND t.task_id = $2 - AND ($3::boolean = false OR (t.created_by = $4 AND t.source = 'manual'))`, + AND ($3::boolean = false OR t.assigned_user_id = $4)`, [organizationId, taskId, isTaskOwnerRestricted(access?.role), access?.userId || null] ); if (!result.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); @@ -4593,7 +4611,7 @@ export class TaskService { const task = await getPool(this.config).query( `SELECT id FROM tasks WHERE organization_id = $1 AND task_id = $2 - AND ($3::boolean = false OR (created_by = $4 AND source = 'manual'))`, + AND ($3::boolean = false OR assigned_user_id = $4)`, [context.organizationId, taskId, this.isOwnerRestrictedUser(context), context.userId || null] ); if (!task.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); @@ -5277,7 +5295,7 @@ export class TaskService { const authorized = await getPool(this.config).query( `SELECT 1 FROM tasks WHERE organization_id = $1 AND task_id = $2 - AND ($3::boolean = false OR (created_by = $4 AND source = 'manual'))`, + AND ($3::boolean = false OR assigned_user_id = $4)`, [organizationId, taskId, isTaskOwnerRestricted(access?.role), access?.userId || null] ); if (!authorized.rowCount) throw new TaskError('artifact_not_found', '附件不存在或无权访问。', 404); @@ -5324,16 +5342,21 @@ export class TaskService { search?: string; includeTotal?: boolean; archive?: 'active' | 'archived' | 'all'; + assignedToUserId?: string; access?: TaskAccessScope; } = {} ): Promise { const status = text(options.status).trim(); const search = text(options.search).trim(); - const params: unknown[] = [organizationId]; + const params: unknown[] = [organizationId]; let where = 't.organization_id = $1'; if (isTaskOwnerRestricted(options.access?.role)) { params.push(options.access?.userId || ''); - where += ` AND t.created_by = $${params.length} AND t.source = 'manual'`; + where += ` AND t.assigned_user_id = $${params.length}`; + } + if (options.assignedToUserId) { + params.push(options.assignedToUserId); + where += ` AND t.assigned_user_id = $${params.length}`; } if (options.archive === 'archived') where += ' AND t.archived_at IS NOT NULL'; else if (options.archive !== 'all') where += ' AND t.archived_at IS NULL'; @@ -5391,6 +5414,7 @@ export class TaskService { `SELECT t.id, t.organization_id, t.task_id, t.source, t.channel_id, uc.display_name AS channel_name, creator.id AS creator_id, creator.username AS creator_username, + assignee.id AS assignee_id, assignee.username AS assignee_username, archiver.id AS archived_by_id, archiver.username AS archived_by_username, t.summary, t.parse_response, t.execution_result, t.status, t.stage, t.message, t.error, @@ -5418,6 +5442,7 @@ export class TaskService { LEFT JOIN user_channels uc ON uc.id = t.channel_id LEFT JOIN agent_sessions s ON s.task_id = t.id LEFT JOIN users creator ON creator.id = t.created_by + LEFT JOIN users assignee ON assignee.id = t.assigned_user_id LEFT JOIN users archiver ON archiver.id = t.archived_by WHERE ${where} ORDER BY t.created_at DESC, t.id DESC LIMIT $${limitParam} OFFSET $${offsetParam}`, @@ -6086,6 +6111,9 @@ export class TaskService { async confirmTask(context: TaskContext, taskId: string): Promise { const outcome = await withTransaction(this.config, async (client) => { const row = await this.lockTaskForAccess(client, context, taskId); + if (!context.userId || text(row.assigned_user_id) !== context.userId) { + throw new TaskError('task_execution_assignee_mismatch', '任务不属于当前账号,禁止确认 ERP 执行。', 403); + } await this.assertTaskCreatorBusinessAuthorizationInTransaction(client, row, context.requestId); if (row.status !== 'awaiting_confirmation') { throw new TaskError('invalid_transition', `任务当前状态为 ${row.status},不能确认 Agent 结果并提交下一步。`); @@ -6150,6 +6178,9 @@ export class TaskService { ); if (!organization.rowCount) throw new TaskError('organization_not_found', '组织不存在。', 404); const row = await this.lockTaskForAccess(client, context, taskId); + if (!context.userId || text(row.assigned_user_id) !== context.userId) { + throw new TaskError('task_execution_assignee_mismatch', '任务不属于当前账号的云电脑,禁止领取执行。', 403); + } await this.assertTaskCreatorBusinessAuthorizationInTransaction(client, row, context.requestId); await this.requireBrowserConnection(client, context, connectionId); const existingAttempt = await client.query( @@ -6191,7 +6222,7 @@ export class TaskService { throw new TaskError('execution_attempt_exists', '任务已有 ERP 执行记录,已阻止再次下发。'); } const activeExecutions = await client.query( - `SELECT t.id, t.task_id, t.status, t.created_by, t.source, a.id AS execution_id + `SELECT t.id, t.task_id, t.status, t.assigned_user_id, a.id AS execution_id FROM tasks t JOIN task_attempts a ON a.task_id = t.id AND a.phase = 'erp' WHERE t.organization_id = $1 @@ -6211,25 +6242,25 @@ export class TaskService { AND status = 'confirmed' AND handoff_status = 'awaiting_handoff' AND ( - source <> 'manual' - OR EXISTS ( - SELECT 1 FROM users creator - WHERE creator.id = tasks.created_by - AND creator.organization_id = tasks.organization_id - AND creator.is_active = true - AND creator.role = 'admin' + EXISTS ( + SELECT 1 FROM users assignee + WHERE assignee.id = tasks.assigned_user_id + AND assignee.organization_id = tasks.organization_id + AND assignee.is_active = true + AND assignee.role = 'admin' + AND tasks.source = 'manual' ) OR EXISTS ( SELECT 1 - FROM users creator + FROM users assignee JOIN user_business_route_authorizations route_grant - ON route_grant.organization_id = creator.organization_id - AND route_grant.user_id = creator.id + ON route_grant.organization_id = assignee.organization_id + AND route_grant.user_id = assignee.id AND route_grant.route_id = tasks.business_route_id - WHERE creator.id = tasks.created_by - AND creator.organization_id = tasks.organization_id - AND creator.is_active = true - AND creator.role IN ('team_lead', 'user') + WHERE assignee.id = tasks.assigned_user_id + AND assignee.organization_id = tasks.organization_id + AND assignee.is_active = true + AND assignee.role IN ('team_lead', 'user') ) ) ORDER BY created_at ASC, id ASC @@ -6244,8 +6275,7 @@ export class TaskService { if (activeOther || queueIndex > 0) { const queuePosition = queueIndex + 1; const activeTaskId = activeOther && canAccessTask(context, { - createdBy: text(activeOther.created_by) || null, - source: normalizeTaskSource(activeOther.source) + assignedUserId: text(activeOther.assigned_user_id) || null }) ? text(activeOther.task_id) : null; await this.audit(client, context, 'task.browser_queued', taskId, { connection_id: connectionId, @@ -6354,6 +6384,9 @@ export class TaskService { try { const outcome = await withTransaction(this.config, async (client) => { const row = await this.lockTaskForAccess(client, context, taskId); + if (!context.userId || text(row.assigned_user_id) !== context.userId) { + throw new TaskError('task_execution_assignee_mismatch', '任务不属于当前账号的云电脑,禁止提交执行结果。', 403); + } await this.requireBrowserConnection(client, context, connectionId); const attempt = await client.query( `SELECT * FROM task_attempts @@ -6964,7 +6997,7 @@ export class TaskService { `SELECT * FROM tasks WHERE organization_id = $1 AND task_id = ANY($2::text[]) - AND ($3::boolean = false OR (created_by = $4 AND source = 'manual')) + AND ($3::boolean = false OR assigned_user_id = $4) ORDER BY task_id FOR UPDATE`, [context.organizationId, normalizedTaskIds, this.isOwnerRestrictedUser(context), context.userId || null] @@ -7077,30 +7110,116 @@ export class TaskService { return this.getTask(context.organizationId, taskId, context); } - async heartbeat(context: TaskContext, connectionId: string, extensionVersion: string, metadata: Record = {}): Promise { - const result = await getPool(this.config).query( - `INSERT INTO browser_connections - (organization_id, user_id, connection_id, extension_version, status, last_seen_at, metadata) - VALUES ($1, $2, $3, $4, 'connected', now(), $5) - ON CONFLICT (organization_id, connection_id) - DO UPDATE SET extension_version = EXCLUDED.extension_version, - status = 'connected', last_seen_at = now(), metadata = EXCLUDED.metadata - WHERE browser_connections.user_id = EXCLUDED.user_id - RETURNING id`, - [context.organizationId, context.userId, connectionId, extensionVersion || null, metadata] - ); - if (!result.rowCount) { - throw new TaskError('browser_connection_not_owned', '浏览器连接已绑定其他账号,请重新生成连接标识。', 403); - } + async heartbeat( + context: TaskContext, + connectionId: string, + extensionVersion: string, + metadata: Record = {}, + routing: { erpAccount?: string; erpAccountMatched?: boolean } = {} + ): Promise<{ execution_ready: boolean; erp_account_matched: boolean; worker_connection_id: string }> { + return withTransaction(this.config, async (client) => { + const account = await client.query( + `SELECT id, role, is_active, erp_account + FROM users + WHERE organization_id = $1 AND id = $2 + FOR UPDATE`, + [context.organizationId, context.userId] + ); + if (!account.rowCount || !databaseBoolean(account.rows[0].is_active)) { + throw new TaskError('browser_account_inactive', '当前平台账号不存在或已停用。', 403); + } + const accountRow = account.rows[0] as Record; + const expectedErpAccount = text(accountRow.erp_account); + const reportedErpAccount = text(routing.erpAccount); + const isAdmin = text(accountRow.role) === 'admin'; + const erpAccountMatched = isAdmin + ? true + : Boolean( + expectedErpAccount + && routing.erpAccountMatched === true + && reportedErpAccount.toLocaleLowerCase() === expectedErpAccount.toLocaleLowerCase() + ); + const executionReady = isAdmin || erpAccountMatched; + + if (executionReady) { + const competing = await client.query( + `SELECT connection_id + FROM browser_connections + WHERE organization_id = $1 + AND user_id = $2 + AND connection_id <> $3 + AND status = 'connected' + AND last_seen_at >= now() - interval '90 seconds' + FOR UPDATE`, + [context.organizationId, context.userId, connectionId] + ); + if (competing.rowCount) { + throw new TaskError( + 'browser_worker_conflict', + '该平台账号已有另一台在线云电脑作为执行端;请退出旧云电脑或等待 90 秒后再连接。', + 409, + { active_connection_id: text(competing.rows[0].connection_id) } + ); + } + + await client.query( + `UPDATE browser_connections + SET status = 'superseded', erp_account_verified = false + WHERE organization_id = $1 + AND user_id = $2 + AND connection_id <> $3 + AND status = 'connected'`, + [context.organizationId, context.userId, connectionId] + ); + } + + const safeMetadata = { + ...metadata, + erp_account_configured: Boolean(expectedErpAccount), + erp_account_matched: erpAccountMatched + }; + delete (safeMetadata as Record).erp_account; + const result = await client.query( + `INSERT INTO browser_connections + (organization_id, user_id, connection_id, extension_version, status, last_seen_at, metadata, + erp_account_verified, erp_account_fingerprint) + VALUES ($1, $2, $3, $4, $5, now(), $6, $7, $8) + ON CONFLICT (organization_id, connection_id) + DO UPDATE SET extension_version = EXCLUDED.extension_version, + status = EXCLUDED.status, last_seen_at = now(), metadata = EXCLUDED.metadata, + erp_account_verified = EXCLUDED.erp_account_verified, + erp_account_fingerprint = EXCLUDED.erp_account_fingerprint + WHERE browser_connections.user_id = EXCLUDED.user_id + RETURNING id`, + [ + context.organizationId, + context.userId, + connectionId, + extensionVersion || null, + executionReady ? 'connected' : 'identity_mismatch', + safeMetadata, + erpAccountMatched, + expectedErpAccount ? sha256Text(expectedErpAccount.toLocaleLowerCase()) : null + ] + ); + if (!result.rowCount) { + throw new TaskError('browser_connection_not_owned', '浏览器连接已绑定其他账号,请重新生成连接标识。', 403); + } + return { + execution_ready: executionReady, + erp_account_matched: erpAccountMatched, + worker_connection_id: connectionId + }; + }); } async eventsSince(organizationId: string, since = 0, access?: TaskAccessScope): Promise { const result = await getPool(this.config).query( - `SELECT e.id, e.organization_id, t.task_id, t.created_by, t.source, + `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.created_by = $4 AND t.source = 'manual')) + AND ($3::boolean = false OR t.assigned_user_id = $4) ORDER BY e.id ASC LIMIT 500`, [organizationId, since, isTaskOwnerRestricted(access?.role), access?.userId || null] ); @@ -7113,7 +7232,7 @@ export class TaskService { message: text(row.message), payload: jsonObject(row.payload), created_at: new Date(String(row.created_at)).toISOString(), - owner_user_id: text(row.created_by) || null, + owner_user_id: text(row.assigned_user_id) || null, task_source: normalizeTaskSource(row.source) })); } diff --git a/control-plane/test/account-authorization.test.ts b/control-plane/test/account-authorization.test.ts index 3b50a2b..ac68683 100644 --- a/control-plane/test/account-authorization.test.ts +++ b/control-plane/test/account-authorization.test.ts @@ -55,6 +55,32 @@ test('business authorization migration adds a fail-closed per-user allowlist for assert.doesNotMatch(sql, /INSERT INTO user_business_route_authorizations[\s\S]+SELECT[\s\S]+FROM users/i); }); +test('AgentBus account-worker migration adds fail-closed channel, task, browser, and ERP identity ownership', async () => { + const sql = await source('../migrations/018_agentbus_account_workers.sql'); + assert.match(sql, /ADD COLUMN IF NOT EXISTS erp_account text/); + assert.match(sql, /users_org_erp_account_unique_idx/); + assert.match(sql, /ADD COLUMN IF NOT EXISTS owner_user_id uuid/); + assert.match(sql, /FOREIGN KEY \(organization_id, owner_user_id\)[\s\S]+REFERENCES users \(organization_id, id\)/); + assert.match(sql, /user_channels_owner_unique_idx/); + assert.match(sql, /ADD COLUMN IF NOT EXISTS assigned_user_id uuid/); + assert.match(sql, /FOREIGN KEY \(organization_id, assigned_user_id\)[\s\S]+REFERENCES users \(organization_id, id\)/); + assert.match(sql, /source = 'manual'[\s\S]+created_by IS NOT NULL/); + assert.match(sql, /erp_account_verified boolean NOT NULL DEFAULT false/); + assert.match(sql, /browser_connections_active_user_unique_idx/); + assert.match(sql, /status = 'superseded'/); + assert.match(sql, /owner_user_id IS NULL[\s\S]+enabled = true/); +}); + +test('AgentBus channel keys and owners are unique so one inbound identity cannot fan out to multiple employees', async () => { + const channels = await source('../src/agentbus-channels.ts'); + assert.match(channels, /requireAssignableOwner/); + assert.match(channels, /requireUniqueAgentBusKey/); + assert.match(channels, /pg_advisory_xact_lock/); + assert.match(channels, /sha256Text\(agentbusKey\)/); + assert.match(channels, /channel_key_conflict/); + assert.match(channels, /channel_owner_conflict/); +}); + test('account lifecycle is administrator-gated and protects passwords, sessions, and the last administrator', async () => { const [auth, server] = await Promise.all([ source('../src/auth.ts'), @@ -71,6 +97,9 @@ test('account lifecycle is administrator-gated and protects passwords, sessions, assert.doesNotMatch(auth, /password\.length < 12|12—512/); assert.match(auth, /account\.password_reset/); assert.match(auth, /account\.password_changed/); + assert.match(auth, /function validateAccountRouting/); + assert.match(auth, /admin_erp_account_forbidden/); + assert.match(auth, /erp_account_conflict/); assert.match(server, /app\.get\('\/api\/accounts'[\s\S]+requireAdminSession\(request\)/); assert.match(server, /app\.post\('\/api\/accounts'[\s\S]+requireAdminMutationSession\(request\)/); assert.match(server, /app\.get\('\/api\/audit'[\s\S]+requireAdminSession\(request\)/); @@ -183,7 +212,7 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event source('../src/task-service.ts'), source('../src/server.ts') ]); - assert.match(tasks, /created_by = \$4 AND source = 'manual'/); + assert.match(tasks, /assigned_user_id = \$4/); assert.match(tasks, /private async lockTaskForAccess/); for (const mutation of ['reparseTaskWithAi', 'confirmTask', 'claimForBrowser', 'recordExecutionResult', 'cancelTask']) { const start = tasks.indexOf(`async ${mutation}(`); @@ -191,11 +220,19 @@ test('ordinary task access is enforced across reads, mutations, artifacts, event const body = tasks.slice(start, start + 20_000); assert.match(body, /lockTaskForAccess\(/, `${mutation} uses the task access lock`); } - assert.match(tasks, /async getTaskArtifact[\s\S]+created_by = \$4 AND source = 'manual'/); - assert.match(tasks, /async eventsSince[\s\S]+t\.created_by = \$4 AND t\.source = 'manual'/); + 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 getTaskInputHistory[\s\S]+actor_user_id/); - assert.match(tasks, /WHERE organization_id = \$1 AND user_id = \$2 AND connection_id = \$3/); + 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/); + assert.match(tasks, /browser_worker_conflict/); + assert.match(tasks, /identity_mismatch/); + assert.match(tasks, /erp_account_mismatch/); + assert.match(tasks, /task_execution_assignee_mismatch/); + assert.match(tasks, /assignee: publicActor\(row\.assignee_id, row\.assignee_username\)/); + const confirmation = tasks.slice(tasks.indexOf('async confirmTask('), tasks.indexOf('async claimForBrowser(')); + assert.match(confirmation, /assigned_user_id/); + assert.match(confirmation, /task_execution_assignee_mismatch/); assert.match(tasks, /i\.actor_user_id IS NOT DISTINCT FROM \$3::uuid/); assert.match(tasks, /private async lockIdempotencyKey/); assert.match(tasks, /pg_advisory_xact_lock/); @@ -215,6 +252,8 @@ test('operator UI exposes role-aware accounts, executive drill-through, original assert.match(index, /href="\/operations-dashboard"/); assert.match(index, /id="passwordChangeForm"/); assert.match(index, /id="accountForm"/); + assert.match(index, /id="accountErpAccount"/); + assert.match(index, /id="channelOwnerUserId"/); assert.doesNotMatch(index, /accountMustChangePassword|首次登录必须修改密码|minlength="12"|12—512/); assert.match(index, /id="accountAuthorizationPanel"/); assert.match(index, /id="accountAuthorizationTypes"/); @@ -240,6 +279,11 @@ test('operator UI exposes role-aware accounts, executive drill-through, original assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/input-history/); assert.match(app, /创建人与原始输入审计/); assert.match(app, /function renderAccountAuthorizationPanel/); + assert.match(app, /function renderChannelOwnerOptions/); + assert.match(app, /function taskAssignedToCurrentAccount/); + assert.match(app, /expected_erp_account/); + assert.match(app, /executable_by=me/); + assert.match(app, /const candidates = Array\.isArray\(result\.tasks\) \? result\.tasks : \[\]/); assert.match(app, /\/business-authorizations/); assert.match(app, /当前默认不能执行任何业务/); assert.match(app, /function canViewOperationsDashboard/); diff --git a/control-plane/test/agentbus.test.ts b/control-plane/test/agentbus.test.ts index 4789c8c..67d6578 100644 --- a/control-plane/test/agentbus.test.ts +++ b/control-plane/test/agentbus.test.ts @@ -145,6 +145,11 @@ function publicChannelFixture(overrides: Partial = {}): P display_name: '外部用户 A', external_user_ref: 'external-user-a', agentbus_bot_address: 'bot:test:listener', + owner_user_id: 'user-a', + owner_username: 'employee-a', + owner_role: 'user', + owner_erp_account: 'ERP-A', + routing_ready: true, enabled: true, status: 'disabled', key_configured: true, @@ -1199,7 +1204,9 @@ test('durable channel listener persists route and resends accepted/result delive displayName: '外部用户 A', wsUrl: 'wss://mesh.nianxx.cn/ws', wsToken: 'channel-ws-token', - botAddress: 'bot:channel-a:listener' + botAddress: 'bot:channel-a:listener', + ownerUserId: 'user-a', + ownerRole: 'user' } }); @@ -1228,6 +1235,8 @@ test('durable channel listener persists route and resends accepted/result delive await new Promise((resolve) => setTimeout(resolve, 50)); } assert.equal(receivedContext.channelId, 'channel-1'); + assert.equal(receivedContext.userId, 'user-a'); + assert.equal(receivedContext.role, 'user'); assert.equal(receivedInput.channelId, 'channel-1'); assert.deepEqual(receivedInput.agentBusRoute, { inboundFrameId: 'channel-durable-1', @@ -1399,7 +1408,9 @@ test('durable roster lifecycle assigns the final result only to the attachment f displayName: '外部用户 A', wsUrl: 'wss://mesh.nianxx.cn/ws', wsToken: 'channel-ws-token', - botAddress: 'bot:channel-a:listener' + botAddress: 'bot:channel-a:listener', + ownerUserId: 'user-a', + ownerRole: 'user' } }); t.after(() => listener.stop()); @@ -1555,7 +1566,9 @@ test('durable channel persists and sends an attachment rejection result while th displayName: '外部用户 A', wsUrl: 'wss://mesh.nianxx.cn/ws', wsToken: 'channel-ws-token', - botAddress: 'bot:channel-a:listener' + botAddress: 'bot:channel-a:listener', + ownerUserId: 'user-a', + ownerRole: 'user' }, logger: { info(metadata, message) { diff --git a/control-plane/test/control-plane.test.ts b/control-plane/test/control-plane.test.ts index 23c5ce5..c683aa7 100644 --- a/control-plane/test/control-plane.test.ts +++ b/control-plane/test/control-plane.test.ts @@ -47,18 +47,18 @@ test('message routing starts a new session for a business directive, not for a s test('task access contract isolates users and team leads while preserving administrator and worker access', () => { const cases = [ - { name: 'administrator sees another user manual task', access: { userId: 'admin', role: 'admin' as const }, createdBy: 'user-a', source: 'manual' as const, allowed: true }, - { name: 'trusted worker sees AgentBus task', access: { userId: '', role: undefined }, createdBy: null, source: 'agentbus' as const, allowed: true }, - { name: 'team lead sees own manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, createdBy: 'lead-a', source: 'manual' as const, allowed: true }, - { name: 'team lead cannot use the normal task path for another manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, createdBy: 'user-b', source: 'manual' as const, allowed: false }, - { name: 'team lead cannot use the normal task path for AgentBus work', access: { userId: 'lead-a', role: 'team_lead' as const }, createdBy: 'lead-a', source: 'agentbus' as const, allowed: false }, - { name: 'ordinary user sees own manual task', access: { userId: 'user-a', role: 'user' as const }, createdBy: 'user-a', source: 'manual' as const, allowed: true }, - { name: 'ordinary user cannot see another manual task', access: { userId: 'user-a', role: 'user' as const }, createdBy: 'user-b', source: 'manual' as const, allowed: false }, - { name: 'ordinary user cannot see AgentBus task', access: { userId: 'user-a', role: 'user' as const }, createdBy: 'user-a', source: 'agentbus' as const, allowed: false }, - { name: 'ordinary user without an actor cannot see a task', access: { userId: '', role: 'user' as const }, createdBy: '', source: 'manual' as const, allowed: false } + { name: 'administrator can inspect another assigned task', access: { userId: 'admin', role: 'admin' as const }, assignedUserId: 'user-a', allowed: true }, + { name: 'trusted worker can inspect an unassigned task', access: { userId: '', role: undefined }, assignedUserId: null, allowed: true }, + { name: 'team lead sees own manual task', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'lead-a', allowed: true }, + { name: 'team lead cannot use the normal task path for another task', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'user-b', allowed: false }, + { name: 'team lead sees own AgentBus work', access: { userId: 'lead-a', role: 'team_lead' as const }, assignedUserId: 'lead-a', allowed: true }, + { name: 'ordinary user sees own manual task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-a', allowed: true }, + { name: 'ordinary user cannot see another task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-b', allowed: false }, + { name: 'ordinary user sees own AgentBus task', access: { userId: 'user-a', role: 'user' as const }, assignedUserId: 'user-a', allowed: true }, + { name: 'ordinary user without an actor cannot see a task', access: { userId: '', role: 'user' as const }, assignedUserId: '', allowed: false } ]; for (const item of cases) { - assert.equal(canAccessTask(item.access, { createdBy: item.createdBy, source: item.source }), item.allowed, item.name); + assert.equal(canAccessTask(item.access, { assignedUserId: item.assignedUserId }), item.allowed, item.name); } assert.equal(isTaskOwnerRestricted('admin'), false); assert.equal(isTaskOwnerRestricted('team_lead'), true); @@ -86,8 +86,14 @@ test('business route authorization is an explicit allowlist for team leads and o role: 'user', source: 'manual', routeId: null, authorizedRouteIds: [routeId] }), false, 'unclassified manual input fails closed for non-administrators'); assert.equal(canExecuteBusinessRoute({ - role: undefined, source: 'agentbus', routeId: null, authorizedRouteIds: [] - }), true, 'trusted AgentBus intake retains its separate administrator-controlled boundary'); + role: 'user', source: 'agentbus', routeId, authorizedRouteIds: [routeId] + }), true, 'bound AgentBus intake uses the employee route allowlist'); + assert.equal(canExecuteBusinessRoute({ + role: undefined, source: 'agentbus', routeId, authorizedRouteIds: [routeId] + }), false, 'unbound AgentBus intake fails closed'); + assert.equal(canExecuteBusinessRoute({ + role: 'admin', source: 'agentbus', routeId, authorizedRouteIds: [routeId] + }), false, 'administrators cannot be AgentBus execution owners'); }); test('field encryption round-trips without storing plaintext', () => { @@ -311,7 +317,7 @@ test('control plane requires the latest durable task-outcome migration before re const { readFile } = await import('node:fs/promises'); const db = await readFile(new URL('../src/db.ts', import.meta.url), 'utf8'); const server = await readFile(new URL('../src/server.ts', import.meta.url), 'utf8'); - assert.equal(REQUIRED_SCHEMA_VERSION, '017_user_business_route_authorizations'); + assert.equal(REQUIRED_SCHEMA_VERSION, '018_agentbus_account_workers'); assert.match(db, /schema_migrations/); assert.match(db, /databaseReadiness/); assert.match(db, /assertDatabaseSchema/); @@ -1226,7 +1232,7 @@ test('operator page has a login gate and uses the durable task API', async () => assert.match(index, /id="loginPanel"/); assert.match(index, /id="workbench"[^>]*hidden/); assert.match(index, /styles\.css\?v=20260902-dashboard-mobile-share-1/); - assert.match(index, /app\.js\?v=20260902-dashboard-filter-fix-2/); + assert.match(index, /app\.js\?v=20260902-agentbus-dashboard-filter-1/); assert.match(index, /id="statusDetailsPopover"/); assert.match(index, /id="statusDetailsRefresh"/); assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/); @@ -1430,7 +1436,7 @@ test('operator page has a login gate and uses the durable task API', async () => assert.doesNotMatch(app, /task-stage-index|task-stage-current/); assert.ok(app.indexOf('/claim') < app.indexOf("sendToExtension('CREATE_TASK'"), 'server claim must precede extension dispatch'); assert.doesNotMatch(app, /syncPendingTasks|>重交 taskAssignedToCurrentAccount\(task\) && isTaskPollable\(task\)\)/); assert.match(app, /execution_id: executionId/); assert.doesNotMatch(bridge, /async function upsertBusinessTask/); assert.match(bridge, /task: currentBusinessTask/); diff --git a/dist/ltjt-order-assistant-0.5.164.zip b/dist/ltjt-order-assistant-0.5.164.zip new file mode 100644 index 0000000..cb085e2 Binary files /dev/null and b/dist/ltjt-order-assistant-0.5.164.zip differ diff --git a/dist/release-manifest.json b/dist/release-manifest.json index bc363a5..40194d7 100644 --- a/dist/release-manifest.json +++ b/dist/release-manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 1, - "generated_on": "2026-09-01", + "generated_on": "2026-09-02", "baselines": { - "chrome_extension": "0.5.163", + "chrome_extension": "0.5.164", "skills": "0.5.125", "business_instruction_docx": "0.5.125", "agent_prompt": "ltjt-agent-prompt-v1.8-independent-headcount-categories" @@ -10,10 +10,10 @@ "artifacts": [ { "kind": "chrome_extension", - "version": "0.5.163", - "path": "dist/ltjt-order-assistant-0.5.163.zip", + "version": "0.5.164", + "path": "dist/ltjt-order-assistant-0.5.164.zip", "source": "chrome-extension/ltjt-order-assistant", - "sha256": "90c550054cdf747aa9c2c80bca5ee5fba0d13f126acfd4779f85400764379bf0" + "sha256": "5a59b616aa7ea2cfcc02b2c3242bfc0b424dcd6ddd28fff7ed9bcfb5b367191a" }, { "kind": "skill", diff --git a/mappings/lifecycle.mapping.json b/mappings/lifecycle.mapping.json index fb49d99..9c282c9 100644 --- a/mappings/lifecycle.mapping.json +++ b/mappings/lifecycle.mapping.json @@ -1,7 +1,7 @@ { "contract_version": "ltjt-lifecycle-v2.9-roster-leader-contact-2026-08", "current_agent_parse_prompt_version": "ltjt-agent-prompt-v1.8-independent-headcount-categories", - "current_extension_version": "0.5.163", + "current_extension_version": "0.5.164", "historical_test_marker": "TEST-202609", "scope": "/System/Business/", "erp_session_keepalive": { diff --git a/tools/lifecycle-contract.test.mjs b/tools/lifecycle-contract.test.mjs index 1ca2e4a..cec9471 100644 --- a/tools/lifecycle-contract.test.mjs +++ b/tools/lifecycle-contract.test.mjs @@ -1296,7 +1296,7 @@ test('passenger explicit server success is terminal without a post-save row requ assert.doesNotMatch(passengerBranch, /passengerRequery|verifyLifecycleOperation/); assert.doesNotMatch(background, /attemptAutomaticPassengerReconciliation/); assert.match(background, /名单已取得 ERP 明确成功响应,按业务规则确认录入成功/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.163'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.164'/); }); test('uncertain lifecycle writes can only converge through a read-only plugin requery', async () => { @@ -1926,7 +1926,7 @@ test('delete guard distinguishes independent, shared child, and shared parent ro for (const operation of operations) assert.equal(plans.validateOperation(operation).ok, true, plans.validateOperation(operation).blockers.join('; ')); const mapping = JSON.parse(await readFile(new URL('../mappings/lifecycle.mapping.json', import.meta.url), 'utf8')); - assert.equal(mapping.current_extension_version, '0.5.163'); + assert.equal(mapping.current_extension_version, '0.5.164'); assert.equal( mapping.updates.order_update_independent.field_mapped_pending_live_validation['pax.child_no_bed'], 'ertrenshu' @@ -2295,7 +2295,8 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov assert.match(background, /if \(report\.manual_review_required === true\) return 'execution_uncertain'/); assert.match(background, /async function readErpSessionStatus/); assert.match(background, /message\?\.type === 'LTJT_ERP_SESSION_STATUS'/); - assert.match(background, /account_matched: bodyText\.includes\('测试ai员工账号'\)/); + assert.match(background, /account_matched: Boolean\(expectedAccount\) && bodyText\.includes\(expectedAccount\)/); + assert.doesNotMatch(background, /bodyText\.includes\('测试ai员工账号'\)/); assert.match(background, /lifecycle_preflight_ambiguous/); assert.match(background, /lifecycle_frame_discovery_retry_exhausted/); assert.match(background, /lifecycleFrameDiscoveryOnly/); @@ -2308,10 +2309,10 @@ test('schema and browser adapters contain the v2 safety fields and no confirm ov assert.match(inpage, /`ys_danweiid\$\{index\}`, resolvedCustomerId/); assert.doesNotMatch(inpage, /product_customer_source_region|sourceRegionCheck|source_reference/); assert.doesNotMatch(teamBatchInpage, /product_customer_source_region|sourceRegionCheck/); - assert.equal(extensionManifest.version, '0.5.163'); - assert.match(inpage, /version: '0\.5\.163'/); - assert.match(teamBatchInpage, /version: '0\.5\.163'/); - assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.163'/); + assert.equal(extensionManifest.version, '0.5.164'); + assert.match(inpage, /version: '0\.5\.164'/); + assert.match(teamBatchInpage, /version: '0\.5\.164'/); + assert.match(platformApp, /REQUIRED_EXTENSION_VERSION = '0\.5\.164'/); assert.match(inpage, /function strictIsoDate\(value\)/); assert.match(inpage, /const startDate = strictIsoDate\(controlCanonicalValue\(form, 'riqi0'\)\)/); assert.match(inpage, /const endDate = strictIsoDate\(controlCanonicalValue\(form, 'riqis0'\)\)/);