diff --git a/.project-docs/30-worklog/tasks/20260901-account-system-impl-d4e7a2.md b/.project-docs/30-worklog/tasks/20260901-account-system-impl-d4e7a2.md new file mode 100644 index 0000000..e57b3b5 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-account-system-impl-d4e7a2.md @@ -0,0 +1,104 @@ +# Task: Implement account isolation, roles, and audit + +## Identity + +- Task ID: 20260901-account-system-impl-d4e7a2 +- Mode: Feature +- Branch: codex/20260901-account-system-impl-d4e7a2-account-system-impl-d4e7a2 +- Worktree: /Users/inmanx/Documents/lwltAPI-account-system-impl-d4e7a2 +- Base commit: ffa33408997c3e822dd8293fbca0739c72ff3d01 +- Owner: codex +- Status: Ready for integration + +## Scope + +- Implement fixed-single-organization `admin`, `team_lead`, and `user` roles without adding organization selection or tenant administration. +- Add administrator account lifecycle APIs and UI, self-service password change, session revocation, and last-administrator safety. +- Enforce administrator-or-owner authorization for manual tasks, messages, events, artifacts, SSE, and browser execution paths. +- Preserve creator and encrypted initial/supplemental input attribution, expose authorized audit history, and replace routine task deletion with archive/restore. +- Add role-aware operator UI and table-driven security/regression coverage. +- Add a team-lead/administrator-only, read-only operations dashboard for cross-user manual instruction history, work-status summaries, multi-dimensional business drill-through, and “who/instruction/result” detail. +- Add administrator-managed per-account task-type authorization for all 18 registered manual business routes, with server-side fail-closed enforcement and denial audit. +- Do not deploy, restart the existing 8786 service, access the real database/ERP, mutate real runtime tasks, or send data externally. A separately isolated local preview on port 8876 and PostgreSQL port 55432 is permitted by the user's explicit preview request. + +## Intent And Constraints + +- Existing `organization_id` remains an internal fixed deployment scope and compatibility boundary; it is not exposed as a product concept. +- Existing users migrate as administrators. Administrators always retain all 18 manual business routes. New ordinary users and team leads start with no manual task-type authorization and can operate only explicitly granted routes for tasks they create; AgentBus/system tasks remain administrator-only and keep their separate channel authorization boundary. +- Team leads gain cross-user visibility only through dedicated read-only dashboard APIs. That capability does not extend normal task mutation, artifact download, SSE, ERP execution, account administration, global audit, automation, parser, or AgentBus permissions. +- Authorization is enforced in server/service/database access paths, never only by navigation visibility. +- Unauthorized resource identifiers fail without revealing cross-user existence. +- Initial and supplemental input remain encrypted at rest; password material and raw business input never enter audit or operational logs. +- Organization-wide ERP execution serialization, confirmation, idempotency, uncertain-write, and requery safety gates remain unchanged. +- Routine deletion becomes reversible archive/restore. Physical purge remains unavailable until an explicit retention policy is authorized. +- Task-type authorization is re-read from PostgreSQL at every creation/continuation and before confirmation, automatic confirmation, and browser claim, so revocation takes effect without waiting for session expiry. A non-administrator instruction that cannot resolve to exactly one registered route fails closed. +- The semantics and ERP contracts of the 18 registered business routes remain unchanged; this feature changes only which signed-in accounts may invoke each route. + +## Permission Contract + +| Surface | Administrator | Team lead | Ordinary user | +|---|---|---|---| +| Login, logout, current user, CSRF, own password | Allowed | Allowed | Allowed | +| Account list/create/status/role/reset/session revocation | Allowed | Denied | Denied | +| Manual task-type authorization | All 18 routes, fixed | Explicit administrator grants only; default none | Explicit administrator grants only; default none | +| Automation, parser routing/review/reparse, AgentBus channels, global audit | Allowed | Denied | Denied | +| Normal task list/search/detail/events/input history | All fixed-scope tasks | Own non-AgentBus tasks only | Own non-AgentBus tasks only | +| Create task/message/confirm/claim/result/cancel/archive/restore | Any authorized task | Own task only | Own task only | +| Artifact download and SSE | All fixed-scope tasks | Own task only | Own task only | +| Operations dashboard summaries and user directory | All manual account tasks | Read-only, all manual account tasks | Denied | +| Operations dashboard instruction/attachment-name/business-result detail | Read-only, all manual account tasks | Read-only, all manual account tasks | Denied | +| Browser heartbeat/connection | Own authenticated connection | Own authenticated connection | Own authenticated connection | +| AgentBus/system tasks | Allowed | Not visible | Not visible | +| Physical task purge | Not exposed | Not exposed | Not exposed | + +Unauthorized task and artifact identifiers use the existing not-found response to avoid enumeration. Administrator-only settings and account endpoints use an explicit forbidden response. + +## Outcome + +- Added migration `015_account_roles_and_task_audit`: existing accounts remain administrators; new `admin`/`user` roles, forced-password lifecycle fields, message/attachment actor attribution, reversible task archive fields, and account-scoped manual idempotency keys are persisted. No organization selector or tenant-management concept was added. +- Added migration `016_team_lead_operations_dashboard`: the fixed role constraint now accepts `team_lead`, and bounded manual-task indexes support time-, actor-, status-, and business-type-oriented dashboard reads. +- Added migration `017_user_business_route_authorizations`: durable fixed-scope per-user allowlists cover all 18 registered manual business routes, carry administrator attribution and optimistic authorization revisions, and enforce same-organization user/granter references. Schema readiness now requires migration 017. +- Implemented administrator account management for list/create, enable/disable, role changes, password reset, and session revocation. Passwords remain Argon2id hashes, reset accounts can be forced to change password, role/status changes revoke sessions, and self-lockout plus removal of the last active administrator are blocked transactionally. +- Enforced owner-restricted access for both ordinary users and team leads on task list/search/detail, messages, input history, artifacts, lifecycle mutations, browser claim/result, archive/restore, historical events, and live SSE. Both roles can access only their own manual tasks through normal business APIs; AgentBus/system tasks remain administrator-only. Browser connection IDs are user-bound and idempotency keys no longer collide across manual accounts. +- Added authorized creator/original-input audit: task creator, every newly attributed user turn, attachment actor, lifecycle event actor, and account/task audit events are available to the authorized operator. Input contents and file names remain encrypted at rest; passwords and plaintext business input are not copied into the global audit log. +- Replaced routine task deletion with archive/restore across the API, UI, retention job, and compatibility routes. Physical task/audit purge code is no longer exposed or retained in the task service. +- Added role-aware platform pages for account management and global audit, mandatory/self-service password change, administrator-only settings navigation, creator/input history, per-user browser connection identity, and active/archived task views. Administrators can assign the new 组长 role. +- Added an administrator-only task-permission panel in account management with all 18 business types, selected counts, select-all/clear, optimistic save protection, and effective authorization display. Administrators are permanently shown as fully authorized; new non-administrator accounts are clearly created with no task permissions until an administrator grants them. +- Enforced the allowlist in the task service before initial parsing/plugin dispatch, on supplemental messages and passenger attachments, inside creation transactions, before manual and automatic confirmation, and before browser/ERP claim. Confirmation and claim evaluate the task creator rather than the acting administrator, so a revoked creator cannot be bypassed by an administrator. Non-administrators receive `business_not_authorized` for a known denied route or `business_type_unresolved` when the route is unknown/non-unique; both paths stop execution and return a business-readable Chinese prompt. Denial audit stores actor, route/revision, phase, and an input hash only—never the plaintext instruction—and explicitly records that parsing, plugin dispatch, and ERP writes did not run. +- Added `/operations-dashboard` and leadership-gated GET APIs with a default 30-day window, a 366-day maximum, status/person/Shanghai-business-day/business-type drill-through, aggregate KPIs, per-user work summaries, bounded pagination, and on-demand business detail. Keyword query matches the creator, full initial and supplemental instructions, business type, readable business result, task record number, group number, and order number after structured filters; decrypted matching is bounded to 2,000 candidates. Dashboard queries include only manual tasks. Its dedicated list/detail contracts expose only who acted, what was instructed, what business outcome was recorded, business type, timestamps, and simplified input attachment attribution; lifecycle events, parser/executor JSON, technical stages/errors, and artifact access data are not returned. No dashboard mutation route exists. +- Updated `control-plane/README.md` to describe the three-role account model, owner isolation, read-only operations dashboard, administrator-managed task-type allowlists, fail-closed enforcement, archive semantics, migrations 015/016/017, and the latest schema readiness gate. +- Added table-driven three-role access-policy tests plus migration, authorization-path, dashboard, UI, audit, idempotency, and no-purge regression assertions. The 18 business routes and ERP serialization/write-safety contracts were not changed. +- Upgraded only the isolated local preview on `127.0.0.1:8876`: applied migration 017 to its dedicated PostgreSQL database, restarted only the preview process, assigned small demonstrative allowlists to the preview lead/Alice/Bob accounts, and verified a denied instruction did not create a task. The existing service on port 8786 remained on its original PID and was not touched. +- No deployment, real-service restart, real database migration, ERP access, real runtime task mutation, secret read, or external message occurred. + +## Verification + +- `node --run check:repo`: 10/10 passed. +- `node --run check`: passed. +- `node --run test:control-plane`: 153/153 passed. +- `node --run test:legacy`: 256/256 passed. +- `node --run build`: passed. +- `node --check LianSyn-platform/app.js`: passed. +- `git diff --check`: passed. +- Focused authorization regression (`node --test --import tsx control-plane/test/account-authorization.test.ts control-plane/test/control-plane.test.ts`): 60/60 passed. +- Disposable PostgreSQL API smoke: new non-administrator denied by default; one granted route accepted; another ungranted route denied; only the authorized task persisted; two denials were durably audited. +- Fresh disposable PostgreSQL migration: migrations 001–017 applied successfully, schema head was `017_user_business_route_authorizations`, and the disposable database was removed afterward. +- Isolated preview readiness: `/health/ready` reported database/schema ready with required migration 017; the account catalog returned all 18 task types; Alice, Bob, and the team lead received 2, 1, and 3 demonstrative grants respectively; Bob's ungranted “安排导游” instruction returned HTTP 403 `business_not_authorized` with the expected Chinese prompt. +- `check_project_docs.py`: passed. +- `check_doc_drift.py --task-id 20260901-account-system-impl-d4e7a2`: passed. + +## Follow-ups + +- Integrate this feature worktree before deployment, then apply migrations `015_account_roles_and_task_audit`, `016_team_lead_operations_dashboard`, and `017_user_business_route_authorizations` and restart the control plane only under separate authorization. +- In an authorized staging/runtime window, create one team-lead and two ordinary test accounts. Verify forced password change, default-deny task permissions, per-route grant/revoke without re-login, known-route and unresolved-route denial prompts, no parsing/plugin/ERP side effects on denial, creator-based revocation at confirmation/claim, own-task visibility, cross-user 404 behavior on normal task/artifact/SSE paths, AgentBus invisibility, dashboard denial for ordinary users, and team-lead cross-user drill-through by status/person/date/business plus keyword hits in supplemental instructions and completed business results. Also verify the dashboard response contains no lifecycle/parser/executor/artifact-access data, then cover account session revocation, creator/input audit, and archive/restore against PostgreSQL and independent browser sessions. +- Keep physical purge unavailable unless a separately approved retention design defines legal/audit retention periods and privileged irreversible-purge controls. + +## Promotion Candidates + +- Target canonical architecture/security/current-state memory during a later Integration task. +- Proposal: record the fixed-scope account model as `admin`, `team_lead`, and `user`, with no product-visible organization concept. Team leads retain ordinary-user ownership boundaries for business operations but receive dedicated cross-user, manual-task-only, read-only operations-dashboard access; administrators retain global settings, AgentBus/system tasks, account lifecycle, and global audit. +- Proposal: record the operations dashboard as a business-facing projection rather than a privileged task-debug surface: status/person/Shanghai-day/business drill-through and bounded plaintext matching are permitted, while lifecycle events, parser/executor payloads, technical stages/codes, and artifact access remain outside its response contract. +- Proposal: record creator/input attribution, account-scoped idempotency, user-bound browser connections, forced password change/session revocation, last-administrator protection, and archive/restore as security invariants. +- Proposal: record manual business eligibility as an administrator-managed allowlist over the 18 registered routes: administrators are always fully authorized, team leads/users default to none and require explicit grants, unknown/non-unique routes fail closed, revocation is checked at each state transition, and AgentBus keeps its independent channel boundary. +- Evidence: `control-plane/migrations/015_account_roles_and_task_audit.sql`, `control-plane/migrations/016_team_lead_operations_dashboard.sql`, `control-plane/migrations/017_user_business_route_authorizations.sql`, `control-plane/src/auth.ts`, `control-plane/src/task-service.ts`, `control-plane/src/server.ts`, `LianSyn-platform/index.html`, `LianSyn-platform/app.js`, `LianSyn-platform/styles.css`, `control-plane/README.md`, and `control-plane/test/account-authorization.test.ts` plus the full verification above. +- Human confirmation required: already received for the single-organization three-role ownership/audit/dashboard behavior; integration, database migration, deployment, restart, and live smoke testing remain separately authorized. diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index 70a1846..5137db8 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -6,8 +6,15 @@ const CURRENT_PAGE = window.location.pathname.replace(/\/+$/, '') || '/'; const IS_HISTORY_PAGE = CURRENT_PAGE === '/history'; const IS_CHANNELS_PAGE = CURRENT_PAGE === '/channels'; const IS_PARSER_ROUTING_PAGE = CURRENT_PAGE === '/parser-routing'; +const IS_ACCOUNTS_PAGE = CURRENT_PAGE === '/accounts'; +const IS_AUDIT_PAGE = CURRENT_PAGE === '/audit'; +const IS_OPERATIONS_DASHBOARD_PAGE = CURRENT_PAGE === '/operations-dashboard'; +const IS_ADMIN_PAGE = IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE || IS_ACCOUNTS_PAGE || IS_AUDIT_PAGE; +const IS_MANAGEMENT_PAGE = IS_ADMIN_PAGE || IS_OPERATIONS_DASHBOARD_PAGE; +const IS_TASK_PAGE = !IS_MANAGEMENT_PAGE; const HOME_TASK_LIMIT = 10; const HISTORY_TASK_PAGE_SIZE = 24; +const OPERATIONS_DASHBOARD_PAGE_SIZE = 50; const DEFAULT_API_TIMEOUT_MS = 15_000; const AUTH_API_TIMEOUT_MS = 10_000; const RESULT_API_TIMEOUT_MS = 20_000; @@ -29,6 +36,8 @@ let remoteSyncInProgress = false; let syncRequested = false; let csrfToken = ''; let authUser = null; +let passwordChangeForced = false; +let browserConnectionId = ''; let organizationAutomationEnabled = false; let automationSettingsLoaded = false; let automationSettingsBusy = false; @@ -38,12 +47,26 @@ let channelList = []; let channelSettingsBusy = false; let parserRoutingRows = []; let parserRoutingBusy = false; +let accountList = []; +let accountTaskTypes = []; +let accountSettingsBusy = false; +let accountAuthorizationTargetId = ''; +let accountAuthorizationDraft = new Set(); +let auditEvents = []; +let auditBusy = false; +let operationsDashboardData = null; +let operationsDashboardBusy = false; +let operationsDashboardOffset = 0; +let operationsDashboardSelectedTaskId = ''; +let operationsDashboardDetail = null; let autoHandoffInProgress = false; let taskStore = []; const runtimeTaskStore = new Map(); const remoteTaskStore = new Map(); const taskDetailStore = new Map(); const taskDetailRequests = new Map(); +const taskInputHistoryStore = new Map(); +const taskInputHistoryRequests = new Map(); const localTaskOverlayStore = new Map(); let pollInProgress = false; let backgroundRefreshInProgress = false; @@ -55,6 +78,7 @@ let taskListMeta = { }; let historySearch = ''; let historyStatus = ''; +let historyArchive = 'active'; let historyOffset = 0; const lifecycleScrollPositions = new Map(); const taskDeleteStates = new Map(); @@ -74,7 +98,6 @@ const persistedExtensionResultVersions = new Map(); let taskCreateInProgress = false; const AUTO_HANDOFF_RETRY_MS = 30_000; -const BROWSER_CONNECTION_ID = `administrator-browser:${location.origin}`; const REQUIRED_EXTENSION_VERSION = '0.5.163'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; @@ -130,6 +153,31 @@ function $(selector) { return document.querySelector(selector); } +function isAdministrator() { + return authUser?.role === 'admin'; +} + +function isTeamLeader() { + return authUser?.role === 'team_lead'; +} + +function canViewOperationsDashboard() { + return isAdministrator() || isTeamLeader(); +} + +function connectionIdForUser(user) { + if (!user?.id) return ''; + const storageKey = `liansyn_platform_browser_connection_id:${user.id}`; + let connectionId = localStorage.getItem(storageKey) || ''; + if (!/^platform-browser:[a-f0-9-]{16,80}$/i.test(connectionId)) { + const identifier = crypto.randomUUID?.() + || Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, '0')).join(''); + connectionId = `platform-browser:${identifier}`; + localStorage.setItem(storageKey, connectionId); + } + return connectionId; +} + function rosterAttachmentError(code, message) { const error = new Error(message); error.code = code; @@ -221,6 +269,7 @@ async function apiRequest(path, options = {}) { function showLoginPanel(message = '') { authUser = null; + browserConnectionId = ''; csrfToken = ''; taskAttachmentDrafts.clear(); const rosterAttachmentInput = $('#rosterAttachment'); @@ -239,16 +288,26 @@ function showLoginPanel(message = '') { const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); + const accountsPage = $('#accountsPage'); + const auditPage = $('#auditPage'); + const operationsDashboardPage = $('#operationsDashboardPage'); + const passwordPanel = $('#passwordChangePanel'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); + const changePasswordButton = $('#changePasswordButton'); if (panel) panel.hidden = false; if (workbench) workbench.hidden = true; if (channelsPage) channelsPage.hidden = true; if (parserRoutingPage) parserRoutingPage.hidden = true; + if (accountsPage) accountsPage.hidden = true; + if (auditPage) auditPage.hidden = true; + if (operationsDashboardPage) operationsDashboardPage.hidden = true; + if (passwordPanel) passwordPanel.hidden = true; if (authState) authState.hidden = true; if (automationButton) automationButton.hidden = true; if (logoutButton) logoutButton.hidden = true; + if (changePasswordButton) changePasswordButton.hidden = true; const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = false; const error = $('#loginError'); @@ -269,16 +328,26 @@ function showAuthChecking() { const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); + const accountsPage = $('#accountsPage'); + const auditPage = $('#auditPage'); + const operationsDashboardPage = $('#operationsDashboardPage'); + const passwordPanel = $('#passwordChangePanel'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); + const changePasswordButton = $('#changePasswordButton'); if (panel) panel.hidden = false; if (workbench) workbench.hidden = true; if (channelsPage) channelsPage.hidden = true; if (parserRoutingPage) parserRoutingPage.hidden = true; + if (accountsPage) accountsPage.hidden = true; + if (auditPage) auditPage.hidden = true; + if (operationsDashboardPage) operationsDashboardPage.hidden = true; + if (passwordPanel) passwordPanel.hidden = true; if (authState) authState.hidden = true; if (automationButton) automationButton.hidden = true; if (logoutButton) logoutButton.hidden = true; + if (changePasswordButton) changePasswordButton.hidden = true; const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = true; const error = $('#loginError'); @@ -287,34 +356,82 @@ function showAuthChecking() { function showAuthenticatedApp(user) { authUser = user; + passwordChangeForced = Boolean(user?.must_change_password); + browserConnectionId = connectionIdForUser(user); + if (!isAdministrator() && IS_ADMIN_PAGE) { + window.location.replace('/'); + return; + } + if (IS_OPERATIONS_DASHBOARD_PAGE && !canViewOperationsDashboard()) { + window.location.replace('/'); + return; + } const panel = $('#loginPanel'); const workbench = $('#workbench'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); + const accountsPage = $('#accountsPage'); + const auditPage = $('#auditPage'); + const operationsDashboardPage = $('#operationsDashboardPage'); + const passwordPanel = $('#passwordChangePanel'); const authState = $('#authState'); const automationButton = $('#automationToggleButton'); const logoutButton = $('#logoutButton'); + const changePasswordButton = $('#changePasswordButton'); if (panel) panel.hidden = true; - if (workbench) workbench.hidden = IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE; - if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE; - if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE; + if (passwordPanel) passwordPanel.hidden = true; + if (workbench) workbench.hidden = IS_MANAGEMENT_PAGE; + if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE || !isAdministrator(); + if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE || !isAdministrator(); + if (accountsPage) accountsPage.hidden = !IS_ACCOUNTS_PAGE || !isAdministrator(); + if (auditPage) auditPage.hidden = !IS_AUDIT_PAGE || !isAdministrator(); + if (operationsDashboardPage) { + operationsDashboardPage.hidden = !IS_OPERATIONS_DASHBOARD_PAGE || !canViewOperationsDashboard(); + } if (authState) { authState.hidden = false; - authState.textContent = `${user.username} · 管理员`; + authState.textContent = `${user.username} · ${accountRoleLabel(user.role)}`; } if (logoutButton) logoutButton.hidden = false; + if (changePasswordButton) changePasswordButton.hidden = false; + for (const selector of ['#channelsNav', '#parserRoutingNav', '#accountsNav', '#auditNav']) { + const nav = $(selector); + if (nav) nav.hidden = !isAdministrator(); + } + const operationsDashboardNav = $('#operationsDashboardNav'); + if (operationsDashboardNav) operationsDashboardNav.hidden = !canViewOperationsDashboard(); if (automationButton) { - automationButton.hidden = IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE; + automationButton.hidden = !isAdministrator() || IS_MANAGEMENT_PAGE; renderAutomationToggle(); } const submitButton = $('#loginForm button[type="submit"]'); if (submitButton) submitButton.disabled = false; + if (user.must_change_password) showPasswordChangePanel(true); +} + +function showPasswordChangePanel(forced = false) { + if (!authUser) return; + passwordChangeForced = forced; + for (const selector of ['#loginPanel', '#workbench', '#channelsPage', '#parserRoutingPage', '#accountsPage', '#auditPage', '#operationsDashboardPage']) { + const page = $(selector); + if (page) page.hidden = true; + } + const panel = $('#passwordChangePanel'); + if (panel) panel.hidden = false; + $('#passwordChangeTitle').textContent = forced ? '请先设置新密码' : '修改密码'; + $('#passwordChangeHint').textContent = forced + ? '管理员已重置你的密码。继续使用平台前,请设置至少 12 个字符的新密码。' + : '新密码至少 12 个字符。修改后,其他已登录会话将失效。'; + $('#passwordChangeCancel').hidden = forced; + $('#passwordChangeError').textContent = ''; + $('#changePasswordButton').hidden = true; + $('#automationToggleButton').hidden = true; } function renderAutomationToggle() { const button = $('#automationToggleButton'); if (!button) return; - button.hidden = !authUser || IS_CHANNELS_PAGE || IS_PARSER_ROUTING_PAGE; + button.hidden = !isAdministrator() || IS_MANAGEMENT_PAGE || passwordChangeForced; button.disabled = !authUser || automationSettingsBusy || !automationSettingsLoaded || Boolean(automationSettingsError); button.setAttribute('aria-pressed', organizationAutomationEnabled ? 'true' : 'false'); button.classList.toggle('is-enabled', organizationAutomationEnabled); @@ -333,7 +450,7 @@ function renderAutomationToggle() { } async function syncAutomationSettings({ background = false } = {}) { - if (!authUser) return null; + if (!isAdministrator()) return null; if (automationSettingsSyncInFlight) return automationSettingsSyncInFlight; if (automationSettingsBusy) return null; const showBusy = !background || !automationSettingsLoaded; @@ -364,7 +481,7 @@ async function syncAutomationSettings({ background = false } = {}) { } async function toggleAutomationSetting() { - if (!authUser) return; + if (!isAdministrator()) return; if (automationSettingsSyncInFlight) { try { await automationSettingsSyncInFlight; @@ -450,8 +567,8 @@ function renderParserRouting() { if (notice) { notice.className = `parser-automation-notice ${organizationAutomationEnabled ? 'is-enabled' : ''}`; notice.textContent = organizationAutomationEnabled - ? '组织全自动化已开启:新任务切入 Auto / Program 后,解析通过可能直接进入 ERP,不附加人工确认期。' - : '组织全自动化已关闭:解析模式变化不会改变当前人工确认设置。'; + ? '平台全自动化已开启:新任务切入 Auto / Program 后,解析通过可能直接进入 ERP,不附加人工确认期。' + : '平台全自动化已关闭:解析模式变化不会改变当前人工确认设置。'; } if (emergency) emergency.disabled = parserRoutingBusy || !parserRoutingRows.length; body.replaceChildren(); @@ -519,7 +636,7 @@ function renderParserRouting() { } async function syncParserRouting() { - if (!authUser) return; + if (!isAdministrator()) return; parserRoutingBusy = true; const message = $('#parserRoutingMessage'); if (message) message.textContent = '正在读取解析策略…'; @@ -544,7 +661,7 @@ async function changeParserRoutingMode(routeId, mode) { : PARSER_MODE_ORDER; if (!route || route.mode_locked || !allowedModes.includes(mode) || parserRoutingBusy || route.mode === mode) return; if (organizationAutomationEnabled && ['auto', 'program'].includes(mode)) { - const confirmed = window.confirm(`组织全自动化已开启。将“${route.directive}”切换到 ${PARSER_MODE_LABELS[mode]} 后,新任务解析通过可能直接进入 ERP。确认继续?`); + const confirmed = window.confirm(`平台全自动化已开启。将“${route.directive}”切换到 ${PARSER_MODE_LABELS[mode]} 后,新任务解析通过可能直接进入 ERP。确认继续?`); if (!confirmed) { renderParserRouting(); return; @@ -657,7 +774,7 @@ function renderChannelList() { } async function syncChannels() { - if (!authUser || IS_HISTORY_PAGE) return null; + if (!isAdministrator()) return null; const result = await apiRequest('/api/channels'); channelList = Array.isArray(result.channels) ? result.channels : []; renderChannelList(); @@ -768,6 +885,621 @@ async function deleteChannel(channelId) { if (message) message.textContent = `“${channel.display_name || '未命名渠道'}”已删除。`; } +function accountRoleLabel(role) { + if (role === 'admin') return '管理员'; + if (role === 'team_lead') return '组长'; + return '普通用户'; +} + +function renderAccounts() { + const container = $('#accountList'); + if (!container) return; + container.replaceChildren(); + if (!accountList.length) { + accountAuthorizationTargetId = ''; + accountAuthorizationDraft = new Set(); + renderAccountAuthorizationPanel(); + container.append(el('p', 'muted channel-empty', '还没有可显示的平台账号。')); + return; + } + for (const account of accountList) { + const row = el('article', 'channel-row'); + const main = el('div', 'channel-row-main'); + const heading = el('div', 'channel-row-heading'); + heading.append(el('strong', '', account.username)); + 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)}${account.must_change_password ? ' · 待修改密码' : ''}`)); + const authorizedCount = Array.isArray(account.authorized_business_route_ids) + ? account.authorized_business_route_ids.length + : 0; + main.append(el( + 'p', + 'muted account-authorization-summary', + account.role === 'admin' + ? `任务权限:全部 ${accountTaskTypes.length || 18} 类业务` + : `任务权限:已授权 ${authorizedCount}/${accountTaskTypes.length || 18} 类业务` + )); + main.append(el('p', 'muted', `最近登录:${account.last_login_at ? new Date(account.last_login_at).toLocaleString('zh-CN') : '尚未登录'}`)); + const actions = el('div', 'channel-row-actions'); + const role = document.createElement('select'); + role.dataset.accountAction = 'role'; + role.dataset.accountId = account.id; + role.innerHTML = ''; + role.value = account.role; + role.disabled = accountSettingsBusy || account.id === authUser?.id; + const toggle = el('button', 'secondary-button', account.is_active ? '停用' : '启用'); + toggle.type = 'button'; + toggle.dataset.accountAction = 'toggle'; + toggle.dataset.accountId = account.id; + toggle.disabled = accountSettingsBusy || account.id === authUser?.id; + const reset = el('button', 'secondary-button', '重置密码'); + reset.type = 'button'; + reset.dataset.accountAction = 'reset-password'; + reset.dataset.accountId = account.id; + reset.disabled = accountSettingsBusy; + const revoke = el('button', 'secondary-button', '撤销会话'); + revoke.type = 'button'; + revoke.dataset.accountAction = 'revoke-sessions'; + revoke.dataset.accountId = account.id; + revoke.disabled = accountSettingsBusy; + const authorizations = el('button', 'secondary-button', account.role === 'admin' ? '全部任务' : '任务权限'); + authorizations.type = 'button'; + authorizations.dataset.accountAction = 'business-authorizations'; + authorizations.dataset.accountId = account.id; + authorizations.disabled = accountSettingsBusy || account.role === 'admin'; + actions.append(role, authorizations, toggle, reset, revoke); + row.append(main, actions); + container.append(row); + } + renderAccountAuthorizationPanel(); +} + +function renderAccountAuthorizationPanel() { + const panel = $('#accountAuthorizationPanel'); + const container = $('#accountAuthorizationTypes'); + if (!panel || !container) return; + const account = accountList.find((item) => item.id === accountAuthorizationTargetId); + if (!account || account.role === 'admin') { + panel.hidden = true; + container.replaceChildren(); + accountAuthorizationTargetId = ''; + accountAuthorizationDraft = new Set(); + return; + } + panel.hidden = false; + $('#accountAuthorizationTitle').textContent = `${account.username} · 任务类型授权`; + $('#accountAuthorizationHint').textContent = '仅勾选的业务可以接收指令、补充信息和名单附件;取消授权后,后续确认与 ERP 领取也会被阻止。'; + container.replaceChildren(); + for (const taskType of accountTaskTypes) { + const label = el('label', `account-authorization-type${accountAuthorizationDraft.has(taskType.route_id) ? ' is-selected' : ''}`); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = accountAuthorizationDraft.has(taskType.route_id); + checkbox.disabled = accountSettingsBusy; + checkbox.dataset.accountAuthorizationRoute = taskType.route_id; + const copy = el('span', 'account-authorization-type-copy'); + copy.append(el('strong', '', taskType.directive || taskType.route_id)); + copy.append(el('small', 'muted', taskType.route_id)); + label.append(checkbox, copy); + container.append(label); + } + const count = accountAuthorizationDraft.size; + $('#accountAuthorizationCount').textContent = `已选择 ${count}/${accountTaskTypes.length} 类业务${count ? '' : ';该账号将不能提交任何业务指令'}`; + $('#accountAuthorizationSelectAll').disabled = accountSettingsBusy || !accountTaskTypes.length; + $('#accountAuthorizationClear').disabled = accountSettingsBusy || !accountAuthorizationDraft.size; + $('#accountAuthorizationSave').disabled = accountSettingsBusy; + $('#accountAuthorizationCancel').disabled = accountSettingsBusy; +} + +function openAccountAuthorizationEditor(accountId) { + const account = accountList.find((item) => item.id === accountId); + if (!account || account.role === 'admin') return; + accountAuthorizationTargetId = account.id; + accountAuthorizationDraft = new Set( + Array.isArray(account.authorized_business_route_ids) ? account.authorized_business_route_ids : [] + ); + renderAccountAuthorizationPanel(); + $('#accountAuthorizationPanel')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function closeAccountAuthorizationEditor() { + accountAuthorizationTargetId = ''; + accountAuthorizationDraft = new Set(); + renderAccountAuthorizationPanel(); +} + +async function saveAccountBusinessAuthorizations() { + if (!isAdministrator() || accountSettingsBusy || !accountAuthorizationTargetId) return; + const account = accountList.find((item) => item.id === accountAuthorizationTargetId); + if (!account || account.role === 'admin') return; + accountSettingsBusy = true; + renderAccounts(); + const message = $('#accountMessage'); + try { + const result = await apiRequest(`/api/accounts/${encodeURIComponent(account.id)}/business-authorizations`, { + method: 'PUT', + body: { + business_route_ids: accountTaskTypes + .map((taskType) => taskType.route_id) + .filter((routeId) => accountAuthorizationDraft.has(routeId)), + expected_revision: Number(account.business_authorization_revision || 0) + } + }); + if (result.account) { + accountList = accountList.map((item) => item.id === account.id ? result.account : item); + } + if (message) message.textContent = `“${account.username}”的任务类型授权已更新,并立即应用于后续指令。`; + accountAuthorizationTargetId = ''; + accountAuthorizationDraft = new Set(); + } finally { + accountSettingsBusy = false; + renderAccounts(); + } +} + +async function syncAccounts() { + if (!isAdministrator()) return; + const result = await apiRequest('/api/accounts'); + accountList = Array.isArray(result.accounts) ? result.accounts : []; + accountTaskTypes = Array.isArray(result.task_types) ? result.task_types : []; + renderAccounts(); +} + +async function createAccountFromForm() { + if (!isAdministrator() || accountSettingsBusy) return; + accountSettingsBusy = true; + renderAccounts(); + const message = $('#accountMessage'); + try { + const result = await apiRequest('/api/accounts', { + method: 'POST', + body: { + username: String($('#accountUsername').value || '').trim(), + password: String($('#accountPassword').value || ''), + role: $('#accountRole').value, + must_change_password: $('#accountMustChangePassword').checked, + business_route_ids: [] + } + }); + if (result.account) accountList = [...accountList.filter((item) => item.id !== result.account.id), result.account] + .sort((left, right) => left.username.localeCompare(right.username, 'zh-CN')); + $('#accountUsername').value = ''; + $('#accountPassword').value = ''; + if (message) { + message.textContent = result.account?.role === 'admin' + ? '管理员账号已创建;该角色固定拥有全部任务权限,初始密码不会再次显示。' + : '账号已创建,当前默认不能执行任何业务;请点击“任务权限”完成授权。'; + } + } finally { + accountSettingsBusy = false; + renderAccounts(); + } +} + +async function updateManagedAccount(accountId, patch) { + if (!isAdministrator() || accountSettingsBusy) return; + accountSettingsBusy = true; + renderAccounts(); + try { + const result = await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}`, { method: 'PATCH', body: patch }); + if (result.account) accountList = accountList.map((item) => item.id === accountId ? result.account : item); + } finally { + accountSettingsBusy = false; + renderAccounts(); + } +} + +async function resetManagedAccountPassword(accountId) { + if (!isAdministrator() || accountSettingsBusy) return; + const password = window.prompt('请输入至少 12 个字符的新密码。账号下次登录时必须修改此密码:'); + if (password == null) return; + accountSettingsBusy = true; + renderAccounts(); + try { + await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}/reset-password`, { + method: 'POST', + body: { password, must_change_password: true } + }); + const message = $('#accountMessage'); + if (message) message.textContent = '密码已重置,该账号的现有会话已全部撤销。'; + await syncAccounts(); + } finally { + accountSettingsBusy = false; + renderAccounts(); + } +} + +async function revokeManagedAccountSessions(accountId) { + if (!isAdministrator() || accountSettingsBusy) return; + if (!window.confirm('确认撤销该账号的全部登录会话?')) return; + accountSettingsBusy = true; + renderAccounts(); + try { + const result = await apiRequest(`/api/accounts/${encodeURIComponent(accountId)}/revoke-sessions`, { + method: 'POST', body: {} + }); + const message = $('#accountMessage'); + if (message) message.textContent = `已撤销 ${Number(result.sessions_revoked || 0)} 个会话。`; + } finally { + accountSettingsBusy = false; + renderAccounts(); + } +} + +function renderAuditEvents() { + const container = $('#auditList'); + if (!container) return; + container.replaceChildren(); + if (!auditEvents.length) { + container.append(el('p', 'muted channel-empty', '暂无符合条件的审计记录。')); + return; + } + for (const event of auditEvents) { + const row = el('article', 'channel-row'); + const main = el('div', 'channel-row-main'); + const heading = el('div', 'channel-row-heading'); + heading.append(el('strong', '', event.event_type || 'unknown')); + heading.append(el('span', 'state state-ok', event.actor?.username || '系统')); + main.append(heading); + main.append(el('p', 'muted', `${event.entity_type || 'entity'} · ${event.entity_id || '—'} · ${new Date(event.created_at).toLocaleString('zh-CN')}`)); + const metadata = el('pre', 'task-lifecycle-console', JSON.stringify(event.metadata || {}, null, 2)); + row.append(main, metadata); + container.append(row); + } +} + +async function syncAuditEvents() { + if (!isAdministrator() || auditBusy) return; + auditBusy = true; + const params = new URLSearchParams({ limit: '100', offset: '0' }); + const eventType = String($('#auditEventType')?.value || '').trim(); + const entityType = String($('#auditEntityType')?.value || '').trim(); + if (eventType) params.set('event_type', eventType); + if (entityType) params.set('entity_type', entityType); + try { + const result = await apiRequest(`/api/audit?${params.toString()}`); + auditEvents = Array.isArray(result.events) ? result.events : []; + renderAuditEvents(); + const message = $('#auditMessage'); + if (message) message.textContent = `共 ${Number(result.total || auditEvents.length)} 条,当前显示 ${auditEvents.length} 条。`; + } finally { + auditBusy = false; + } +} + +function operationsDashboardDateValue(date) { + const parts = Object.fromEntries(new Intl.DateTimeFormat('en', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).formatToParts(date).map((part) => [part.type, part.value])); + return `${parts.year}-${parts.month}-${parts.day}`; +} + +function resetOperationsDashboardFilters() { + const today = new Date(); + const from = new Date(today.getTime() - 29 * 24 * 60 * 60 * 1_000); + $('#operationsDashboardFrom').value = operationsDashboardDateValue(from); + $('#operationsDashboardTo').value = operationsDashboardDateValue(today); + $('#operationsDashboardActor').value = ''; + $('#operationsDashboardBusiness').value = ''; + $('#operationsDashboardStatus').value = 'all'; + $('#operationsDashboardSearch').value = ''; + operationsDashboardOffset = 0; +} + +function operationsDashboardRange() { + const fromValue = String($('#operationsDashboardFrom')?.value || ''); + const toValue = String($('#operationsDashboardTo')?.value || ''); + const from = new Date(`${fromValue}T00:00:00+08:00`); + const toInclusive = new Date(`${toValue}T00:00:00+08:00`); + if (!fromValue || !toValue || Number.isNaN(from.getTime()) || Number.isNaN(toInclusive.getTime())) { + throw new Error('请选择有效的开始与结束日期。'); + } + if (toInclusive.getTime() < from.getTime()) throw new Error('结束日期不能早于开始日期。'); + const toExclusive = new Date(toInclusive); + toExclusive.setDate(toExclusive.getDate() + 1); + return { from: from.toISOString(), to: toExclusive.toISOString() }; +} + +function operationsDashboardStatusAppearance(outcomeKind) { + if (outcomeKind === 'failed') return 'state-bad'; + if (outcomeKind === 'completed') return 'state-ok'; + return 'state-warn'; +} + +function renderOperationsDashboardActorOptions(actors) { + const select = $('#operationsDashboardActor'); + if (!select) return; + const selected = select.value; + select.replaceChildren(new Option('全部账号', '')); + for (const actor of actors || []) { + const state = actor.is_active ? '' : ' · 已停用'; + select.append(new Option(`${actor.username} · ${accountRoleLabel(actor.role)}${state}`, actor.id)); + } + if ([...select.options].some((option) => option.value === selected)) select.value = selected; +} + +function renderOperationsDashboardBusinessOptions(options) { + const select = $('#operationsDashboardBusiness'); + if (!select) return; + const selected = select.value; + select.replaceChildren(new Option('全部业务', '')); + for (const option of options || []) { + select.append(new Option(option.label || option.key, option.key)); + } + if ([...select.options].some((option) => option.value === selected)) select.value = selected; +} + +function renderOperationsDashboardActiveFilters() { + const container = $('#operationsDashboardActiveFilters'); + if (!container) return; + const actor = $('#operationsDashboardActor')?.selectedOptions?.[0]?.textContent || '全部账号'; + const business = $('#operationsDashboardBusiness')?.selectedOptions?.[0]?.textContent || '全部业务'; + const status = $('#operationsDashboardStatus')?.selectedOptions?.[0]?.textContent || '全部状态'; + const from = String($('#operationsDashboardFrom')?.value || ''); + const to = String($('#operationsDashboardTo')?.value || ''); + const search = String($('#operationsDashboardSearch')?.value || '').trim(); + container.textContent = `当前范围:${from} 至 ${to} · ${actor} · ${business} · ${status}${search ? ` · 关键词“${search}”` : ''}`; +} + +function renderOperationsDashboardSummary() { + const container = $('#operationsDashboardSummary'); + if (!container) return; + container.replaceChildren(); + const summary = operationsDashboardData?.summary || {}; + const cards = [ + ['总指令', summary.total || 0, '查看全部结果', 'all'], + ['进行中', summary.active || 0, '仍在处理', 'active'], + ['已完成', summary.completed || 0, '已有业务结果', 'completed'], + ['待处理', summary.attention || 0, '待补充、确认或核验', 'attention'], + ['异常', summary.failed || 0, '未正常完成', 'failed'], + ['已取消', summary.cancelled || 0, '主动取消的指令', 'cancelled'], + ['已归档', summary.archived || 0, '历史归档记录', 'archived'], + ['涉及用户', summary.user_count || 0, '有操作记录的账号', ''] + ]; + const selectedStatus = String($('#operationsDashboardStatus')?.value || 'all'); + for (const [label, value, hint, status] of cards) { + const card = el(status ? 'button' : 'article', `operations-dashboard-stat${status && selectedStatus === status ? ' is-active' : ''}`); + if (status) { + card.type = 'button'; + card.dataset.operationsStatus = status; + card.setAttribute('aria-label', `查看${label}指令`); + } + card.append(el('span', '', label)); + card.append(el('strong', '', String(value))); + card.append(el('small', '', hint)); + container.append(card); + } +} + +function renderOperationsDashboardBreakdownList(containerSelector, values, datasetKey, emptyText) { + const container = $(containerSelector); + if (!container) return; + container.replaceChildren(); + if (!(values || []).length) { + container.append(el('p', 'muted channel-empty', emptyText)); + return; + } + for (const item of values || []) { + const button = el('button', 'operations-dashboard-breakdown-row'); + button.type = 'button'; + button.dataset[datasetKey] = item.key; + const heading = el('span', 'operations-dashboard-breakdown-heading'); + heading.append(el('strong', '', item.label), el('b', '', String(item.total))); + const metrics = el('small', 'muted', `完成 ${item.completed} · 待处理 ${item.attention} · 异常 ${item.failed} · 取消 ${item.cancelled || 0}`); + button.append(heading, metrics); + container.append(button); + } +} + +function renderOperationsDashboardBreakdowns() { + renderOperationsDashboardBreakdownList( + '#operationsDashboardDays', + operationsDashboardData?.days, + 'operationsDay', + '当前范围内没有可按日期查看的数据。' + ); + renderOperationsDashboardBreakdownList( + '#operationsDashboardBusinesses', + operationsDashboardData?.businesses, + 'operationsBusiness', + '当前范围内没有可按业务查看的数据。' + ); +} + +function renderOperationsDashboardUsers() { + const container = $('#operationsDashboardUsers'); + if (!container) return; + container.replaceChildren(); + const users = operationsDashboardData?.users || []; + $('#operationsDashboardUserCount').textContent = `${users.length} 个有操作记录的账号`; + if (!users.length) { + container.append(el('p', 'muted channel-empty', '当前筛选范围内没有用户操作记录。')); + return; + } + for (const item of users) { + const button = el('button', 'operations-dashboard-user-row'); + button.type = 'button'; + button.dataset.operationsActorId = item.actor.id; + const heading = el('div', 'operations-dashboard-row-heading'); + heading.append(el('strong', '', item.actor.username)); + heading.append(el('span', 'muted', accountRoleLabel(item.actor.role))); + const metrics = el('div', 'operations-dashboard-user-metrics'); + metrics.append( + el('span', '', `总计 ${item.total}`), + el('span', '', `完成 ${item.completed}`), + el('span', '', `待处理 ${item.attention}`), + el('span', item.failed ? 'is-error' : '', `异常 ${item.failed}`), + el('span', '', `取消 ${item.cancelled || 0}`) + ); + button.append(heading, metrics, el('small', 'muted', `最近活动:${formatDateTime(item.last_activity_at)}`)); + container.append(button); + } +} + +function renderOperationsDashboardTasks() { + const container = $('#operationsDashboardTasks'); + if (!container) return; + container.replaceChildren(); + const tasks = operationsDashboardData?.tasks || []; + const total = Number(operationsDashboardData?.total || 0); + $('#operationsDashboardTaskCount').textContent = `共 ${total} 条`; + if (!tasks.length) { + container.append(el('p', 'muted channel-empty', '当前筛选范围内没有指令操作记录。')); + } + for (const task of tasks) { + const button = el('button', `operations-dashboard-task-row${operationsDashboardSelectedTaskId === task.task_id ? ' is-selected' : ''}`); + button.type = 'button'; + button.dataset.operationsTaskId = task.task_id; + const heading = el('div', 'operations-dashboard-row-heading'); + heading.append(el('strong', '', `${task.creator.username} · ${task.business_route_label}`)); + heading.append(el('span', `state ${operationsDashboardStatusAppearance(task.outcome_kind)}`, task.outcome_label)); + const meta = el('p', 'muted', `${formatDateTime(task.created_at)} · ${task.input_turn_count} 次指令${task.archived_at ? ' · 已归档' : ''} · 记录号 ${task.task_id}`); + const instruction = el('p', 'operations-dashboard-instruction', `指令:${task.instruction_preview || '(内容为空)'}`); + const result = el('p', 'operations-dashboard-result', `结果:${task.result_summary || '暂无业务结果'}`); + button.append(heading, meta, instruction, result); + container.append(button); + } + const start = total ? operationsDashboardOffset + 1 : 0; + const end = operationsDashboardOffset + tasks.length; + $('#operationsDashboardPageInfo').textContent = total ? `${start}–${end} / ${total}` : '0 条'; + $('#operationsDashboardPrevious').disabled = operationsDashboardBusy || operationsDashboardOffset <= 0; + $('#operationsDashboardNext').disabled = operationsDashboardBusy || !operationsDashboardData?.has_more; +} + +function renderOperationsDashboard() { + renderOperationsDashboardActiveFilters(); + renderOperationsDashboardSummary(); + renderOperationsDashboardBreakdowns(); + renderOperationsDashboardUsers(); + renderOperationsDashboardTasks(); +} + +function appendOperationsDashboardDetailSection(container, title, content) { + const section = el('section', 'operations-dashboard-detail-section'); + section.append(el('h3', '', title)); + section.append(content); + container.append(section); +} + +function renderOperationsDashboardDetail() { + const panel = $('#operationsDashboardDetail'); + const container = $('#operationsDashboardDetailBody'); + if (!panel || !container) return; + container.replaceChildren(); + const detail = operationsDashboardDetail; + if (!detail?.task_id) { + panel.hidden = true; + return; + } + panel.hidden = false; + $('#operationsDashboardDetailTitle').textContent = `指令业务详情 · ${detail.creator?.username || '未知账号'}`; + + const facts = el('div', 'operations-dashboard-detail-facts'); + appendField(facts, '操作人', detail.creator?.username || '未知账号'); + appendField(facts, '业务类型', detail.business_route_label || '未识别业务'); + appendField(facts, '提交时间', formatDateTime(detail.created_at)); + appendField(facts, '业务结果状态', detail.outcome_label || '处理中'); + appendField(facts, '结果记录时间', formatDateTime(detail.result_recorded_at)); + appendField(facts, '归档状态', detail.archived_at ? `已归档 · ${formatDateTime(detail.archived_at)}` : '未归档'); + appendOperationsDashboardDetailSection(container, '业务概况', facts); + + const inputs = el('div', 'operations-dashboard-input-history'); + for (const message of detail.instructions || []) { + const item = el('article', 'operations-dashboard-input-item'); + item.append(el('strong', '', `第 ${message.turn_no} 次指令 · ${message.actor?.username || detail.creator?.username || '历史账号'}`)); + item.append(el('span', 'muted', formatDateTime(message.created_at))); + item.append(el('pre', '', message.content || '(内容为空)')); + inputs.append(item); + } + if (!inputs.childElementCount) inputs.append(el('p', 'muted', '没有可显示的指令内容。')); + appendOperationsDashboardDetailSection(container, '指令内容', inputs); + + const attachments = el('div', 'operations-dashboard-attachments'); + for (const attachment of detail.attachments || []) { + const item = el('article', 'operations-dashboard-attachment-row'); + item.append(el('strong', '', attachment.file_name || '未命名附件')); + item.append(el('span', 'muted', `${attachment.actor?.username || detail.creator?.username || '历史账号'} · ${formatDateTime(attachment.created_at)}${attachment.row_count == null ? '' : ` · ${attachment.row_count} 行数据`}`)); + attachments.append(item); + } + if (attachments.childElementCount) { + appendOperationsDashboardDetailSection(container, '随指令提交的附件', attachments); + } + appendOperationsDashboardDetailSection( + container, + '完成结果', + el('pre', 'operations-dashboard-result-detail', detail.result_summary || '暂无业务结果。') + ); +} + +async function syncOperationsDashboard() { + if (!canViewOperationsDashboard() || operationsDashboardBusy) return; + operationsDashboardBusy = true; + renderOperationsDashboardTasks(); + const message = $('#operationsDashboardMessage'); + if (message) message.textContent = '正在读取操作数据…'; + try { + const range = operationsDashboardRange(); + const params = new URLSearchParams({ + from: range.from, + to: range.to, + status: String($('#operationsDashboardStatus')?.value || 'all'), + limit: String(OPERATIONS_DASHBOARD_PAGE_SIZE), + offset: String(operationsDashboardOffset) + }); + const actorUserId = String($('#operationsDashboardActor')?.value || ''); + const businessRouteId = String($('#operationsDashboardBusiness')?.value || ''); + const search = String($('#operationsDashboardSearch')?.value || '').trim(); + if (actorUserId) params.set('actor_user_id', actorUserId); + if (businessRouteId) params.set('business_route_id', businessRouteId); + if (search) params.set('search', search); + const result = await apiRequest(`/api/operations-dashboard?${params.toString()}`); + operationsDashboardData = result; + renderOperationsDashboardActorOptions(result.actors || []); + renderOperationsDashboardBusinessOptions(result.business_options || []); + renderOperationsDashboard(); + if (message) message.textContent = `查询完成:${Number(result.total || 0)} 条人工指令记录。可继续按状态、人员、日期或业务穿透,点击记录查看“谁、指令、结果”。`; + } finally { + operationsDashboardBusy = false; + renderOperationsDashboardTasks(); + } +} + +async function loadOperationsDashboardTask(taskId) { + if (!canViewOperationsDashboard() || !taskId) return; + operationsDashboardSelectedTaskId = taskId; + operationsDashboardDetail = null; + renderOperationsDashboardTasks(); + const panel = $('#operationsDashboardDetail'); + const container = $('#operationsDashboardDetailBody'); + panel.hidden = false; + container.replaceChildren(el('p', 'muted', '正在读取指令与业务结果…')); + $('#operationsDashboardDetailTitle').textContent = '指令业务详情'; + const result = await apiRequest(`/api/operations-dashboard/tasks/${encodeURIComponent(taskId)}`); + if (operationsDashboardSelectedTaskId !== taskId) return; + operationsDashboardDetail = result; + renderOperationsDashboardDetail(); + panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); +} + +function closeOperationsDashboardDetail() { + operationsDashboardSelectedTaskId = ''; + operationsDashboardDetail = null; + $('#operationsDashboardDetail').hidden = true; + renderOperationsDashboardTasks(); +} + +function refreshOperationsDashboardAfterDrilldown() { + operationsDashboardOffset = 0; + closeOperationsDashboardDetail(); + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = error.message || String(error); + }); +} + async function refreshCsrfToken() { const result = await apiRequest('/api/auth/csrf'); csrfToken = result.csrf_token || ''; @@ -790,6 +1522,7 @@ async function syncRemoteTasks() { }); if (IS_HISTORY_PAGE && historySearch) params.set('search', historySearch); if (IS_HISTORY_PAGE && historyStatus) params.set('status', historyStatus); + if (IS_HISTORY_PAGE) params.set('archive', historyArchive); const result = await apiRequest(`/api/tasks?${params.toString()}`); // Keep the server snapshot authoritative, but reject older event/timestamp // versions and preserve a short-lived local overlay until the result is @@ -797,8 +1530,9 @@ async function syncRemoteTasks() { taskStore = (Array.isArray(result.tasks) ? result.tasks : []) .map(mergeRemoteTask) .filter((task) => ( - task?.status !== 'cancelled' - && task?.result?.status !== 'cancelled' + (historyArchive === 'archived' || ( + task?.status !== 'cancelled' && task?.result?.status !== 'cancelled' + )) && !locallyDeletedTaskIds.has(task?.task_id) )); const pagination = result.pagination || {}; @@ -1026,13 +1760,35 @@ async function ensureTaskDetails(taskId, { force = false } = {}) { return request; } +async function ensureTaskInputHistory(taskId, { force = false } = {}) { + if (!force && taskInputHistoryStore.has(taskId)) return taskInputHistoryStore.get(taskId); + const existingRequest = taskInputHistoryRequests.get(taskId); + if (existingRequest) return existingRequest; + const request = apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/input-history`, { timeoutMs: RESULT_API_TIMEOUT_MS }) + .then((result) => { + const history = { + messages: Array.isArray(result.messages) ? result.messages : [], + attachments: Array.isArray(result.attachments) ? result.attachments : [] + }; + taskInputHistoryStore.set(taskId, history); + if (currentTaskId === taskId) renderTaskDetail(); + return history; + }) + .finally(() => taskInputHistoryRequests.delete(taskId)); + taskInputHistoryRequests.set(taskId, request); + return request; +} + function loadSelectedTaskDetails(task) { - if (!authUser || !task || isDetailedTask(task)) return; - const lifecycle = $('#taskLifecycle'); - if (lifecycle) lifecycle.textContent = '正在加载任务详情…'; - void ensureTaskDetails(task.task_id).catch((error) => { - if (lifecycle?.isConnected) lifecycle.textContent = `任务详情加载失败:${error.message || String(error)}`; - }); + if (!authUser || !task) return; + if (!isDetailedTask(task)) { + const lifecycle = $('#taskLifecycle'); + if (lifecycle) lifecycle.textContent = '正在加载任务详情…'; + void ensureTaskDetails(task.task_id).catch((error) => { + if (lifecycle?.isConnected) lifecycle.textContent = `任务详情加载失败:${error.message || String(error)}`; + }); + } + void ensureTaskInputHistory(task.task_id).catch(() => {}); } function upsertLocalTask(task, { forceVisible = true, source = 'server' } = {}) { @@ -1503,7 +2259,12 @@ function isTaskPollable(task) { } function canDeleteTask(task) { - return Boolean(task); + if (!task) return false; + if (task.archived_at) return true; + return ![ + 'parse_queued', 'parse_running', 'confirmed', 'queued', 'accepted', 'running' + ].includes(String(task.status || '')) + && !['awaiting_handoff', 'accepted', 'running'].includes(String(task.handoff_status || '')); } function canStartConfirmedTask(task) { @@ -1549,16 +2310,13 @@ async function autoDispatchReadyTasks({ force = false } = {}) { if (!authUser || autoHandoffInProgress) return; autoHandoffInProgress = true; try { - let candidates = taskStore; - if (organizationAutomationEnabled) { - 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); - cacheRuntimeTask(task); - } - candidates = [...byId.values()]; + 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); + 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; @@ -2441,6 +3199,7 @@ function parserDifferenceNode(entry) { function parserReviewNode(decisionId, reviewStatus) { const review = el('div', 'task-parser-review'); review.append(el('span', 'muted', `人工判定:${reviewStatus || 'pending'}`)); + if (!isAdministrator()) return review; const actions = el('div', 'task-parser-review-actions'); const options = [ ['equivalent', '等价'], @@ -2495,7 +3254,8 @@ function renderTaskParserPanel(task) { } const failure = taskFailureInfo(task); - const canReparse = task.status === 'parse_failed' + const canReparse = isAdministrator() + && task.status === 'parse_failed' && Number(parser.reparse_count || 0) < 1 && failure?.no_plugin_dispatch === true && failure?.no_erp_write === true; @@ -2547,6 +3307,42 @@ async function reparseTaskWithAi(taskId, button) { } } +function renderTaskInputAudit(task) { + if (!task?.task_id) return null; + 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 / 系统' : '历史记录未知')}`)); + const history = taskInputHistoryStore.get(task.task_id); + if (!history) { + panel.append(el('p', 'muted', '正在加载加密输入历史…')); + return panel; + } + for (const message of history.messages) { + const item = el('article', 'detail-block'); + const sourceLabel = message.source === 'manual' ? '人工输入' + : message.source === 'agentbus' ? 'AgentBus' + : message.source === 'reparse' ? '管理员重解析' : '历史/系统'; + item.append(el( + 'p', + 'muted', + `第 ${message.turn_no} 轮 · ${message.actor?.username || '未知操作人'} · ${sourceLabel} · ${new Date(message.created_at).toLocaleString('zh-CN')}` + )); + item.append(el('pre', 'task-lifecycle-console', message.content || '')); + panel.append(item); + } + for (const attachment of history.attachments) { + panel.append(el( + 'p', + 'muted', + `附件:${attachment.file_name || '未命名'} · ${attachment.actor?.username || '未知操作人'} · ${attachment.status} · SHA-256 ${String(attachment.sha256 || '').slice(0, 16)}…` + )); + } + if (!history.messages.length && !history.attachments.length) { + panel.append(el('p', 'muted', '没有可显示的输入审计记录。')); + } + return panel; +} + function renderTaskDetail() { const container = $('#taskDetail'); if (!container) return; @@ -2585,9 +3381,9 @@ function renderTaskDetail() { if (deleteButton) { const deleteState = task ? taskDeleteStates.get(task.task_id) : ''; deleteButton.disabled = !canDeleteTask(task) || deleteState === 'deleting'; - if (deleteState === 'deleting') deleteButton.textContent = '删除中…'; - else if (deleteState === 'failed') deleteButton.textContent = '重试删除'; - else deleteButton.textContent = '删除任务'; + if (deleteState === 'deleting') deleteButton.textContent = task?.archived_at ? '恢复中…' : '归档中…'; + else if (deleteState === 'failed') deleteButton.textContent = task?.archived_at ? '重试恢复' : '重试归档'; + else deleteButton.textContent = task?.archived_at ? '恢复任务' : '归档任务'; } if (taskState) { taskState.textContent = task ? taskStatusLabel(task) : '未创建'; @@ -2613,6 +3409,8 @@ function renderTaskDetail() { const panels = []; if (importantMessagePanel) panels.push(importantMessagePanel); + const inputAuditPanel = renderTaskInputAudit(task); + if (inputAuditPanel) panels.push(inputAuditPanel); const parserPanel = renderTaskParserPanel(task); if (parserPanel) panels.push(parserPanel); const operationReviewPanel = renderOperationReview(task); @@ -2851,10 +3649,16 @@ function configurePageMode() { document.body.classList.toggle('history-page', IS_HISTORY_PAGE); document.body.classList.toggle('channels-page-active', IS_CHANNELS_PAGE); document.body.classList.toggle('parser-routing-page-active', IS_PARSER_ROUTING_PAGE); + document.body.classList.toggle('accounts-page-active', IS_ACCOUNTS_PAGE); + document.body.classList.toggle('audit-page-active', IS_AUDIT_PAGE); + document.body.classList.toggle('operations-dashboard-page-active', IS_OPERATIONS_DASHBOARD_PAGE); const instructionPanel = document.querySelector('.instruction-panel'); const historyFilters = $('#historyFilters'); const channelsPage = $('#channelsPage'); const parserRoutingPage = $('#parserRoutingPage'); + const accountsPage = $('#accountsPage'); + const auditPage = $('#auditPage'); + const operationsDashboardPage = $('#operationsDashboardPage'); const title = $('#taskListTitle'); const hint = $('#taskListHint'); const historyLink = $('#historyLink'); @@ -2862,19 +3666,27 @@ function configurePageMode() { const historyNav = $('#historyNav'); const channelsNav = $('#channelsNav'); const parserRoutingNav = $('#parserRoutingNav'); + const accountsNav = $('#accountsNav'); + const auditNav = $('#auditNav'); + const operationsDashboardNav = $('#operationsDashboardNav'); if (instructionPanel) instructionPanel.hidden = IS_HISTORY_PAGE; if (channelsPage) channelsPage.hidden = !IS_CHANNELS_PAGE; if (parserRoutingPage) parserRoutingPage.hidden = !IS_PARSER_ROUTING_PAGE; + if (accountsPage) accountsPage.hidden = !IS_ACCOUNTS_PAGE; + if (auditPage) auditPage.hidden = !IS_AUDIT_PAGE; + if (operationsDashboardPage) operationsDashboardPage.hidden = !IS_OPERATIONS_DASHBOARD_PAGE; if (historyFilters) historyFilters.hidden = !IS_HISTORY_PAGE; if (title) title.textContent = IS_HISTORY_PAGE ? '历史任务目录' : '任务卡片'; - if (hint) hint.textContent = IS_HISTORY_PAGE ? '全部未删除任务' : '最近任务'; + if (hint) hint.textContent = IS_HISTORY_PAGE + ? (historyArchive === 'archived' ? '已归档任务' : '未归档任务') + : '最近任务'; if (historyLink) { historyLink.href = IS_HISTORY_PAGE ? '/' : '/history'; historyLink.textContent = IS_HISTORY_PAGE ? '返回 AI 操作台' : '查看更多'; historyLink.hidden = !IS_HISTORY_PAGE && taskListMeta.total <= HOME_TASK_LIMIT; } if (workbenchNav) { - const workbenchActive = !IS_HISTORY_PAGE && !IS_CHANNELS_PAGE && !IS_PARSER_ROUTING_PAGE; + const workbenchActive = !IS_HISTORY_PAGE && !IS_MANAGEMENT_PAGE; workbenchNav.classList.toggle('is-active', workbenchActive); if (!workbenchActive) workbenchNav.removeAttribute('aria-current'); else workbenchNav.setAttribute('aria-current', 'page'); @@ -2894,13 +3706,33 @@ function configurePageMode() { if (IS_PARSER_ROUTING_PAGE) parserRoutingNav.setAttribute('aria-current', 'page'); else parserRoutingNav.removeAttribute('aria-current'); } - if (IS_PARSER_ROUTING_PAGE) document.title = '解析策略 · LianSyn-platform'; + if (accountsNav) { + accountsNav.classList.toggle('is-active', IS_ACCOUNTS_PAGE); + if (IS_ACCOUNTS_PAGE) accountsNav.setAttribute('aria-current', 'page'); + else accountsNav.removeAttribute('aria-current'); + } + if (auditNav) { + auditNav.classList.toggle('is-active', IS_AUDIT_PAGE); + if (IS_AUDIT_PAGE) auditNav.setAttribute('aria-current', 'page'); + else auditNav.removeAttribute('aria-current'); + } + if (operationsDashboardNav) { + operationsDashboardNav.classList.toggle('is-active', IS_OPERATIONS_DASHBOARD_PAGE); + if (IS_OPERATIONS_DASHBOARD_PAGE) operationsDashboardNav.setAttribute('aria-current', 'page'); + else operationsDashboardNav.removeAttribute('aria-current'); + } + if (IS_ACCOUNTS_PAGE) document.title = '账号管理 · LianSyn-platform'; + else if (IS_AUDIT_PAGE) document.title = '审计记录 · LianSyn-platform'; + else if (IS_OPERATIONS_DASHBOARD_PAGE) document.title = '操作数据看板 · LianSyn-platform'; + else if (IS_PARSER_ROUTING_PAGE) document.title = '解析策略 · LianSyn-platform'; else if (IS_CHANNELS_PAGE) document.title = 'AgentBus 渠道 · LianSyn-platform'; else if (IS_HISTORY_PAGE) document.title = '历史任务 · LianSyn-platform'; const searchInput = $('#historySearchInput'); const statusInput = $('#historyStatusInput'); + const archiveInput = $('#historyArchiveInput'); if (searchInput && searchInput.value !== historySearch) searchInput.value = historySearch; if (statusInput && statusInput.value !== historyStatus) statusInput.value = historyStatus; + if (archiveInput && archiveInput.value !== historyArchive) archiveInput.value = historyArchive; } function renderTaskListHeader() { @@ -2946,8 +3778,8 @@ function resetHistoryTaskSelection({ clearMessage = true } = {}) { function renderHistoryBatchActions(visibleTasks = []) { const actions = $('#historyBatchActions'); if (!actions) return; - actions.hidden = !IS_HISTORY_PAGE; - if (!IS_HISTORY_PAGE) return; + actions.hidden = !IS_HISTORY_PAGE || historyArchive === 'archived'; + if (!IS_HISTORY_PAGE || historyArchive === 'archived') return; const visibleTaskIds = new Set(visibleTasks.map((task) => task?.task_id).filter(Boolean)); for (const taskId of [...historySelectedTaskIds]) { @@ -2973,10 +3805,10 @@ function renderHistoryBatchActions(visibleTasks = []) { if (deleteButton) { deleteButton.disabled = historyBulkDeleteBusy || selectedCount === 0; deleteButton.textContent = historyBulkDeleteBusy - ? `正在删除 ${selectedCount} 个…` + ? `正在归档 ${selectedCount} 个…` : selectedCount > 0 - ? `删除所选 (${selectedCount})` - : '删除所选'; + ? `归档所选 (${selectedCount})` + : '归档所选'; } actions.setAttribute('aria-busy', historyBulkDeleteBusy ? 'true' : 'false'); } @@ -3050,17 +3882,19 @@ function renderTaskCards() { card.querySelector('.task-card-status .task-pill').textContent = taskStatusLabel(task); if (IS_HISTORY_PAGE) { card.classList.add('has-history-actions'); - const selection = document.createElement('label'); - selection.className = 'task-card-select'; - selection.title = `选择任务 ${task.task_id}`; - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.dataset.historyTaskSelect = task.task_id; - checkbox.checked = historySelectedTaskIds.has(task.task_id); - checkbox.disabled = historyBulkDeleteBusy || deleteState === 'deleting'; - checkbox.setAttribute('aria-label', `选择任务 ${task.task_id}`); - selection.append(checkbox); - card.prepend(selection); + if (historyArchive !== 'archived') { + const selection = document.createElement('label'); + selection.className = 'task-card-select'; + selection.title = `选择任务 ${task.task_id}`; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.historyTaskSelect = task.task_id; + checkbox.checked = historySelectedTaskIds.has(task.task_id); + checkbox.disabled = historyBulkDeleteBusy || deleteState === 'deleting'; + checkbox.setAttribute('aria-label', `选择任务 ${task.task_id}`); + selection.append(checkbox); + card.prepend(selection); + } const deleteButton = document.createElement('button'); deleteButton.type = 'button'; @@ -3068,11 +3902,11 @@ function renderTaskCards() { deleteButton.dataset.historyTaskDelete = task.task_id; deleteButton.disabled = historyBulkDeleteBusy || deleteState === 'deleting'; deleteButton.textContent = deleteState === 'deleting' - ? '删除中…' + ? (task.archived_at ? '恢复中…' : '归档中…') : deleteState === 'failed' - ? '重试删除' - : '删除'; - deleteButton.setAttribute('aria-label', `彻底删除任务 ${task.task_id}`); + ? (task.archived_at ? '重试恢复' : '重试归档') + : (task.archived_at ? '恢复' : '归档'); + deleteButton.setAttribute('aria-label', `${task.archived_at ? '恢复' : '归档'}任务 ${task.task_id}`); card.append(deleteButton); } container.appendChild(card); @@ -3623,7 +4457,7 @@ async function handoffTaskToExtension(task) { try { claim = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/claim`, { method: 'POST', - body: { connection_id: BROWSER_CONNECTION_ID } + body: { connection_id: browserConnectionId } }); if (claim.task) upsertLocalTask(claim.task); if (!claim.claimed) { @@ -3692,7 +4526,7 @@ async function handoffTaskToExtension(task) { const persisted = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/result`, { method: 'POST', body: { - connection_id: BROWSER_CONNECTION_ID, + connection_id: browserConnectionId, execution_id: claim.execution_id, result: uncertainResult } @@ -3753,7 +4587,7 @@ async function pingBridge() { await apiRequest('/api/connections/heartbeat', { method: 'POST', body: { - connection_id: `administrator-browser:${location.origin}`, + connection_id: browserConnectionId, extension_version: result.version || '', metadata: { bridge_installed_at: result.bridge_installed_at || '' } } @@ -3834,12 +4668,10 @@ async function refreshBackgroundState() { if (!authUser || backgroundRefreshInProgress) return; backgroundRefreshInProgress = true; try { - await Promise.allSettled([ - pingAi(), - pingBridge(), - syncAutomationSettings({ background: true }), - syncRemoteTasks() - ]); + const operations = [pingAi(), pingBridge()]; + if (isAdministrator()) operations.push(syncAutomationSettings({ background: true })); + if (IS_TASK_PAGE) operations.push(syncRemoteTasks()); + await Promise.allSettled(operations); } finally { backgroundRefreshInProgress = false; } @@ -3858,39 +4690,19 @@ async function pollResult() { }); } -function taskNeedsPluginCancellation(task) { - return Boolean( - task?.confirmed_at - || ['accepted', 'syncing', 'waiting_extension', 'paused'].includes(task?.handoff_status) - ); -} - -function requestPluginTaskStop(task) { - if (!taskNeedsPluginCancellation(task)) return; - // Platform deletion is authoritative. Do not make the operator wait for - // an extension response; stopping the extension is best effort only and - // a write already in flight cannot be rolled back here. - void sendToExtension('DELETE_TASK', { task_id: task.task_id }, 4000).catch(() => {}); -} - -function confirmTaskHardDelete(tasks) { +function confirmTaskArchive(tasks) { const selectedTasks = Array.isArray(tasks) ? tasks.filter(Boolean) : []; if (!selectedTasks.length) return false; const scope = selectedTasks.length === 1 ? `任务 ${selectedTasks[0].task_id}` : `所选 ${selectedTasks.length} 个任务`; - const pluginTaskCount = selectedTasks.filter(taskNeedsPluginCancellation).length; - const pluginWarning = pluginTaskCount > 0 - ? `\n\n其中 ${pluginTaskCount} 个任务已确认或进入插件流程;平台只会尽力通知插件停止,已经发生的 ERP 写入不会被撤回。` - : ''; return window.confirm( - `确认彻底删除${scope}?\n\n任务记录、生命周期、附件和持久化回执将不可恢复。${pluginWarning}` + `确认归档${scope}?\n\n归档后任务不会出现在日常列表中,但创建人、原始输入、生命周期、附件和回执都会保留,可从“已归档”范围恢复。` ); } function removeDeletedTaskLocally(taskId) { const wasVisible = taskStore.some((item) => item.task_id === taskId); - locallyDeletedTaskIds.add(taskId); taskDeleteStates.delete(taskId); historySelectedTaskIds.delete(taskId); taskStore = taskStore.filter((item) => item.task_id !== taskId); @@ -3898,6 +4710,8 @@ function removeDeletedTaskLocally(taskId) { remoteTaskStore.delete(taskId); taskDetailStore.delete(taskId); taskDetailRequests.delete(taskId); + taskInputHistoryStore.delete(taskId); + taskInputHistoryRequests.delete(taskId); localTaskOverlayStore.delete(taskId); pendingExtensionResults.delete(taskId); persistedExtensionResultVersions.delete(taskId); @@ -3931,14 +4745,18 @@ async function deleteTask(taskId) { renderTaskCards(); try { - requestPluginTaskStop(task); - await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}`, { - method: 'DELETE', + const restoring = Boolean(task.archived_at); + await apiRequest( + restoring + ? `/api/tasks/${encodeURIComponent(taskId)}/restore` + : `/api/tasks/${encodeURIComponent(taskId)}/archive`, { + method: 'POST', + body: {}, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); removeDeletedTaskLocally(taskId); if (IS_HISTORY_PAGE) { - historyBatchMessage = `任务 ${taskId} 已彻底删除。`; + historyBatchMessage = `任务 ${taskId} 已${restoring ? '恢复' : '归档'}。`; historyBatchMessageKind = 'success'; } finishLocalTaskDeletions(); @@ -3946,17 +4764,17 @@ async function deleteTask(taskId) { } catch (error) { taskDeleteStates.set(taskId, 'failed'); if (IS_HISTORY_PAGE) { - historyBatchMessage = `未删除任务 ${taskId}:${error.message || String(error)}`; + historyBatchMessage = `任务 ${taskId} 操作失败:${error.message || String(error)}`; historyBatchMessageKind = 'error'; } - if (currentTaskId === taskId) setTaskState('删除失败'); + if (currentTaskId === taskId) setTaskState('归档/恢复失败'); setOutput({ - status: 'delete_error', + status: 'archive_error', task_id: taskId, - message: `未删除任务:${error.message || String(error)}` + message: `任务归档/恢复失败:${error.message || String(error)}` }); renderTaskCards(); - if (currentTaskId === taskId) setTaskState('删除失败'); + if (currentTaskId === taskId) setTaskState('归档/恢复失败'); throw error; } } @@ -3964,36 +4782,35 @@ async function deleteTask(taskId) { async function deleteHistorySelectedTasks() { if (!IS_HISTORY_PAGE || historyBulkDeleteBusy) return; const selectedTasks = taskStore.filter((task) => historySelectedTaskIds.has(task.task_id)); - if (!selectedTasks.length || !confirmTaskHardDelete(selectedTasks)) return; + if (!selectedTasks.length || !confirmTaskArchive(selectedTasks)) return; const taskIds = selectedTasks.map((task) => task.task_id); historyBulkDeleteBusy = true; - historyBatchMessage = `正在彻底删除 ${taskIds.length} 个任务…`; + historyBatchMessage = `正在归档 ${taskIds.length} 个任务…`; historyBatchMessageKind = ''; for (const task of selectedTasks) { taskDeleteStates.set(task.task_id, 'deleting'); - requestPluginTaskStop(task); } renderTaskCards(); try { - await apiRequest('/api/tasks/bulk-delete', { + await apiRequest('/api/tasks/bulk-archive', { method: 'POST', body: { task_ids: taskIds }, timeoutMs: TASK_DELETE_API_TIMEOUT_MS }); for (const taskId of taskIds) removeDeletedTaskLocally(taskId); resetHistoryTaskSelection({ clearMessage: false }); - historyBatchMessage = `已彻底删除 ${taskIds.length} 个任务。`; + historyBatchMessage = `已归档 ${taskIds.length} 个任务。`; historyBatchMessageKind = 'success'; finishLocalTaskDeletions(); void syncRemoteTasks().catch(() => {}); } catch (error) { for (const taskId of taskIds) taskDeleteStates.set(taskId, 'failed'); - historyBatchMessage = `批量删除失败,未删除所选任务:${error.message || String(error)}`; + historyBatchMessage = `批量归档失败,所选任务均未变更:${error.message || String(error)}`; historyBatchMessageKind = 'error'; - setTaskState('批量删除失败'); + setTaskState('批量归档失败'); setOutput({ - status: 'bulk_delete_error', + status: 'bulk_archive_error', task_ids: taskIds, message: historyBatchMessage }); @@ -4028,7 +4845,7 @@ async function persistExtensionTaskResult(taskId, result) { const persisted = await apiRequest(`/api/tasks/${encodeURIComponent(taskId)}/result`, { method: 'POST', body: { - connection_id: BROWSER_CONNECTION_ID, + connection_id: browserConnectionId, execution_id: executionId, result } @@ -4199,38 +5016,57 @@ async function initializeSession() { } showAuthenticatedApp(me.user); - try { - await syncAutomationSettings(); - } catch (error) { - setTaskState('全自动化设置读取失败'); - } - if (IS_PARSER_ROUTING_PAGE) { + if (me.user?.must_change_password) return true; + if (isAdministrator()) { try { - await syncParserRouting(); + await syncAutomationSettings(); } catch (error) { - // The parser page remains available and displays its own load error. + setTaskState('全自动化设置读取失败'); } } - try { - await syncChannels(); - } catch (error) { - const message = $('#channelMessage'); - if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; + if (IS_PARSER_ROUTING_PAGE && isAdministrator()) { + await syncParserRouting().catch(() => {}); } - try { - await syncRemoteTasks(); - } catch (error) { - // A task-list/database failure must not erase a valid administrator - // session. Keep the workbench available and surface the sync error. - setTaskState('任务同步失败'); - setOutput({ status: 'sync_error', message: error.message || String(error) }); + if (IS_CHANNELS_PAGE && isAdministrator()) { + await syncChannels().catch((error) => { + const message = $('#channelMessage'); + if (message) message.textContent = `渠道读取失败:${error.message || String(error)}`; + }); + } + if (IS_ACCOUNTS_PAGE && isAdministrator()) { + await syncAccounts().catch((error) => { + const message = $('#accountMessage'); + if (message) message.textContent = `账号读取失败:${error.message || String(error)}`; + }); + } + if (IS_AUDIT_PAGE && isAdministrator()) { + await syncAuditEvents().catch((error) => { + const message = $('#auditMessage'); + if (message) message.textContent = `审计读取失败:${error.message || String(error)}`; + }); + } + if (IS_OPERATIONS_DASHBOARD_PAGE && canViewOperationsDashboard()) { + await syncOperationsDashboard().catch((error) => { + const message = $('#operationsDashboardMessage'); + if (message) message.textContent = `看板读取失败:${error.message || String(error)}`; + }); + } + if (IS_TASK_PAGE) { + try { + await syncRemoteTasks(); + } catch (error) { + // A task-list/database failure must not erase a valid session. + setTaskState('任务同步失败'); + setOutput({ status: 'sync_error', message: error.message || String(error) }); + } + startRemoteEventStream(); } - startRemoteEventStream(); return true; } document.addEventListener('DOMContentLoaded', async () => { configurePageMode(); + if (IS_OPERATIONS_DASHBOARD_PAGE) resetOperationsDashboardFilters(); // Remove credentials/configuration left by the retired browser-side parser. localStorage.removeItem('liansyn_platform_agent_config'); sessionStorage.removeItem('liansyn_platform_agent_api_key'); @@ -4281,20 +5117,21 @@ document.addEventListener('DOMContentLoaded', async () => { csrfToken = result.csrf_token || ''; showAuthenticatedApp(result.user); $('#loginPassword').value = ''; - try { - await syncAutomationSettings(); - } catch (error) { - setTaskState('全自动化设置读取失败'); + if (result.user?.must_change_password) return; + if (isAdministrator()) { + await syncAutomationSettings().catch(() => setTaskState('全自动化设置读取失败')); } - if (IS_PARSER_ROUTING_PAGE) await syncParserRouting().catch(() => {}); - try { - await syncChannels(); - } catch (error) { + 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_ACCOUNTS_PAGE && isAdministrator()) await syncAccounts(); + if (IS_AUDIT_PAGE && isAdministrator()) await syncAuditEvents(); + if (IS_TASK_PAGE) { + await syncRemoteTasks(); + startRemoteEventStream(); } - await syncRemoteTasks(); - startRemoteEventStream(); await pingAi(); await pingBridge(); } catch (error) { @@ -4303,6 +5140,40 @@ document.addEventListener('DOMContentLoaded', async () => { if (submitButton) submitButton.disabled = false; } }); + $('#changePasswordButton').addEventListener('click', () => showPasswordChangePanel(false)); + $('#passwordChangeCancel').addEventListener('click', () => { + if (passwordChangeForced || !authUser) return; + showAuthenticatedApp(authUser); + }); + $('#passwordChangeForm').addEventListener('submit', async (event) => { + event.preventDefault(); + const errorNode = $('#passwordChangeError'); + const currentPassword = String($('#currentPassword').value || ''); + const newPassword = String($('#newPassword').value || ''); + const confirmation = String($('#confirmNewPassword').value || ''); + if (newPassword !== confirmation) { + errorNode.textContent = '两次输入的新密码不一致。'; + return; + } + const submit = $('#passwordChangeForm button[type="submit"]'); + submit.disabled = true; + errorNode.textContent = ''; + try { + await apiRequest('/api/auth/password', { + method: 'PUT', + body: { current_password: currentPassword, new_password: newPassword } + }); + $('#currentPassword').value = ''; + $('#newPassword').value = ''; + $('#confirmNewPassword').value = ''; + passwordChangeForced = false; + await initializeSession(); + } catch (error) { + errorNode.textContent = error.message || String(error); + } finally { + submit.disabled = false; + } + }); $('#logoutButton').addEventListener('click', async () => { try { await apiRequest('/api/auth/logout', { method: 'POST', body: {} }); @@ -4314,6 +5185,8 @@ document.addEventListener('DOMContentLoaded', async () => { remoteTaskStore.clear(); taskDetailStore.clear(); taskDetailRequests.clear(); + taskInputHistoryStore.clear(); + taskInputHistoryRequests.clear(); localTaskOverlayStore.clear(); extensionResultPersistQueues.clear(); pendingExtensionResults.clear(); @@ -4322,11 +5195,16 @@ document.addEventListener('DOMContentLoaded', async () => { taskReplyStates.clear(); taskReplyDrafts.clear(); taskAttachmentDrafts.clear(); + accountList = []; + accountTaskTypes = []; + accountAuthorizationTargetId = ''; + accountAuthorizationDraft = new Set(); historySelectedTaskIds.clear(); locallyDeletedTaskIds.clear(); historyBatchMessage = ''; historyBatchMessageKind = ''; currentTaskId = ''; + browserConnectionId = ''; sessionStorage.removeItem('liansyn_platform_current_task_id'); $('#rosterAttachment').value = ''; $('#rosterAttachmentStatus').textContent = ''; @@ -4349,9 +5227,11 @@ document.addEventListener('DOMContentLoaded', async () => { event.preventDefault(); historySearch = normalizeText($('#historySearchInput').value).slice(0, 200); historyStatus = normalizeText($('#historyStatusInput').value).slice(0, 80); + historyArchive = $('#historyArchiveInput').value === 'archived' ? 'archived' : 'active'; historyOffset = 0; currentTaskId = ''; resetHistoryTaskSelection(); + configurePageMode(); syncRemoteTasks().catch((error) => { setTaskState('历史任务筛选失败'); setOutput({ status: 'history_sync_error', message: error.message || String(error) }); @@ -4360,6 +5240,7 @@ document.addEventListener('DOMContentLoaded', async () => { $('#historyResetButton').addEventListener('click', () => { historySearch = ''; historyStatus = ''; + historyArchive = 'active'; historyOffset = 0; currentTaskId = ''; resetHistoryTaskSelection(); @@ -4402,6 +5283,144 @@ document.addEventListener('DOMContentLoaded', async () => { setOutput({ status: 'automation_settings_error', message: error.message || String(error) }); }); }); + $('#accountForm')?.addEventListener('submit', (event) => { + event.preventDefault(); + createAccountFromForm().catch((error) => { + const message = $('#accountMessage'); + if (message) message.textContent = error.message || String(error); + }); + }); + $('#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 message = $('#accountMessage'); + if (message) message.textContent = error.message || String(error); + void syncAccounts(); + }); + }); + $('#accountList')?.addEventListener('click', (event) => { + const button = event.target.closest('button[data-account-action][data-account-id]'); + if (!button) return; + const account = accountList.find((item) => item.id === button.dataset.accountId); + if (!account) return; + const action = button.dataset.accountAction; + let operation; + if (action === 'business-authorizations') { + openAccountAuthorizationEditor(account.id); + return; + } + 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; + operation.catch((error) => { + const message = $('#accountMessage'); + if (message) message.textContent = error.message || String(error); + }); + }); + $('#accountAuthorizationTypes')?.addEventListener('change', (event) => { + const checkbox = event.target.closest('input[data-account-authorization-route]'); + if (!checkbox || accountSettingsBusy) return; + const routeId = checkbox.dataset.accountAuthorizationRoute; + if (checkbox.checked) accountAuthorizationDraft.add(routeId); + else accountAuthorizationDraft.delete(routeId); + renderAccountAuthorizationPanel(); + }); + $('#accountAuthorizationSelectAll')?.addEventListener('click', () => { + if (accountSettingsBusy) return; + accountAuthorizationDraft = new Set(accountTaskTypes.map((taskType) => taskType.route_id)); + renderAccountAuthorizationPanel(); + }); + $('#accountAuthorizationClear')?.addEventListener('click', () => { + if (accountSettingsBusy) return; + accountAuthorizationDraft = new Set(); + renderAccountAuthorizationPanel(); + }); + $('#accountAuthorizationSave')?.addEventListener('click', () => { + void saveAccountBusinessAuthorizations().catch((error) => { + const message = $('#accountMessage'); + if (message) message.textContent = error.message || String(error); + }); + }); + $('#accountAuthorizationCancel')?.addEventListener('click', closeAccountAuthorizationEditor); + $('#auditFilters')?.addEventListener('submit', (event) => { + event.preventDefault(); + void syncAuditEvents().catch((error) => { + const message = $('#auditMessage'); + if (message) message.textContent = error.message || String(error); + }); + }); + $('#auditResetButton')?.addEventListener('click', () => { + $('#auditEventType').value = ''; + $('#auditEntityType').value = ''; + void syncAuditEvents(); + }); + $('#operationsDashboardFilters')?.addEventListener('submit', (event) => { + event.preventDefault(); + operationsDashboardOffset = 0; + closeOperationsDashboardDetail(); + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = error.message || String(error); + }); + }); + $('#operationsDashboardReset')?.addEventListener('click', () => { + resetOperationsDashboardFilters(); + closeOperationsDashboardDetail(); + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = error.message || String(error); + }); + }); + $('#operationsDashboardUsers')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-actor-id]'); + if (!button) return; + $('#operationsDashboardActor').value = button.dataset.operationsActorId || ''; + refreshOperationsDashboardAfterDrilldown(); + }); + $('#operationsDashboardSummary')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-status]'); + if (!button) return; + $('#operationsDashboardStatus').value = button.dataset.operationsStatus || 'all'; + refreshOperationsDashboardAfterDrilldown(); + }); + $('#operationsDashboardDays')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-day]'); + if (!button?.dataset.operationsDay) return; + $('#operationsDashboardFrom').value = button.dataset.operationsDay; + $('#operationsDashboardTo').value = button.dataset.operationsDay; + refreshOperationsDashboardAfterDrilldown(); + }); + $('#operationsDashboardBusinesses')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-business]'); + if (!button?.dataset.operationsBusiness) return; + $('#operationsDashboardBusiness').value = button.dataset.operationsBusiness; + refreshOperationsDashboardAfterDrilldown(); + }); + $('#operationsDashboardTasks')?.addEventListener('click', (event) => { + const button = event.target.closest('[data-operations-task-id]'); + if (!button) return; + void loadOperationsDashboardTask(button.dataset.operationsTaskId).catch((error) => { + $('#operationsDashboardMessage').textContent = `详情读取失败:${error.message || String(error)}`; + closeOperationsDashboardDetail(); + }); + }); + $('#operationsDashboardPrevious')?.addEventListener('click', () => { + if (operationsDashboardOffset <= 0 || operationsDashboardBusy) return; + operationsDashboardOffset = Math.max(0, operationsDashboardOffset - OPERATIONS_DASHBOARD_PAGE_SIZE); + closeOperationsDashboardDetail(); + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = error.message || String(error); + }); + }); + $('#operationsDashboardNext')?.addEventListener('click', () => { + if (!operationsDashboardData?.has_more || operationsDashboardBusy) return; + operationsDashboardOffset += OPERATIONS_DASHBOARD_PAGE_SIZE; + closeOperationsDashboardDetail(); + void syncOperationsDashboard().catch((error) => { + $('#operationsDashboardMessage').textContent = error.message || String(error); + }); + }); + $('#operationsDashboardDetailClose')?.addEventListener('click', closeOperationsDashboardDetail); $('#parserRoutingRows')?.addEventListener('change', (event) => { const select = event.target.closest('select[data-parser-mode-route]'); if (!select) return; @@ -4478,9 +5497,9 @@ document.addEventListener('DOMContentLoaded', async () => { $('#deleteTaskButton').addEventListener('click', () => { const task = selectedTask(); if (!task) return; - if (!confirmTaskHardDelete([task])) return; + if (!task.archived_at && !confirmTaskArchive([task])) return; deleteSelectedTask().catch((error) => setOutput({ - status: 'delete_error', + status: 'archive_error', task_id: task.task_id, message: error.message || String(error) })); @@ -4500,7 +5519,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (deleteButton) { const taskId = deleteButton.dataset.historyTaskDelete; const task = taskStore.find((item) => item.task_id === taskId); - if (task && confirmTaskHardDelete([task])) { + if (task && (task.archived_at || confirmTaskArchive([task]))) { void deleteTask(taskId).catch(() => {}); } return; @@ -4535,7 +5554,8 @@ document.addEventListener('DOMContentLoaded', async () => { void copyTaskLifecycle(button); }); if (await initializeSession()) { - renderTaskCards(); + if (authUser?.must_change_password) return; + if (IS_TASK_PAGE) renderTaskCards(); pingAi().catch(() => {}); pingBridge().catch(() => {}); // The control plane caches this health probe; keep the browser refresh @@ -4554,7 +5574,7 @@ document.addEventListener('DOMContentLoaded', async () => { window.addEventListener('focus', () => { refreshBackgroundState().catch(() => {}); }); - if (currentTaskId) renderTaskDetail(); - if (hasPollableRuntimeTasks()) startPolling(); + if (IS_TASK_PAGE && currentTaskId) renderTaskDetail(); + if (IS_TASK_PAGE && hasPollableRuntimeTasks()) startPolling(); } }); diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index d5c7c05..70f275e 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -5,7 +5,7 @@ AI操作台 · LianSyn-platform - +
@@ -16,12 +16,16 @@
+ + +
+ + + + + + + + + + +
- + diff --git a/LianSyn-platform/styles.css b/LianSyn-platform/styles.css index f6c71ab..ddf1486 100644 --- a/LianSyn-platform/styles.css +++ b/LianSyn-platform/styles.css @@ -551,7 +551,8 @@ textarea { font-weight: 700; } -.channel-form input { +.channel-form input, +.channel-form select { min-height: 34px; padding: 6px 8px; font-size: 12px; @@ -627,6 +628,122 @@ textarea { font-size: 11px; } +.channel-row-actions select { + min-height: 28px; + padding: 0 8px; + font-size: 11px; +} + +.account-panel { + grid-template-rows: auto auto auto minmax(0, 1fr) auto; +} + +.account-authorization-summary { + color: var(--ink) !important; +} + +.account-authorization-panel { + display: grid; + gap: 12px; + padding: 12px; + border: 1px solid #aebcc4; + border-radius: 8px; + background: #ffffff; +} + +.account-authorization-head, +.account-authorization-footer { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.account-authorization-head h3 { + margin-top: 2px; + font-size: 15px; +} + +.account-authorization-head .muted { + max-width: 760px; + margin-top: 4px; + font-size: 12px; + line-height: 1.5; +} + +.account-authorization-head-actions, +.account-authorization-footer .actions { + display: flex; + flex: 0 0 auto; + gap: 7px; +} + +.account-authorization-head-actions button, +.account-authorization-footer button { + min-height: 30px; + padding: 0 10px; + font-size: 11px; +} + +.account-authorization-types { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + max-height: 260px; + overflow: auto; +} + +.account-authorization-type { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 7px; + background: var(--surface-muted); + cursor: pointer; +} + +.account-authorization-type:hover, +.account-authorization-type.is-selected { + border-color: #8197a2; + background: #edf3f5; +} + +.account-authorization-type input { + width: 15px; + height: 15px; + margin-top: 1px; + accent-color: var(--ink); +} + +.account-authorization-type-copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.account-authorization-type-copy strong, +.account-authorization-type-copy small { + min-width: 0; + overflow-wrap: anywhere; +} + +.account-authorization-type-copy strong { + font-size: 12px; + line-height: 1.35; +} + +.account-authorization-type-copy small { + font-size: 10px; +} + +.account-authorization-footer { + align-items: center; + padding-top: 2px; +} + .panel { min-width: 0; min-height: 0; @@ -1882,6 +1999,16 @@ textarea { flex-direction: column; } + .account-authorization-head, + .account-authorization-footer { + align-items: stretch; + flex-direction: column; + } + + .account-authorization-types { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .metric { border-right: 0; border-bottom: 1px solid var(--line); @@ -2926,6 +3053,366 @@ textarea { max-height: none; } +.audit-page-active .channel-row { + display: grid; + align-items: stretch; +} + +.audit-page-active .channel-row > pre { + max-height: 180px; + margin: 0; +} + +.operations-dashboard-page { + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 14px; + overflow: auto; +} + +.operations-dashboard-panel { + min-height: 100%; + display: grid; + grid-template-rows: auto auto auto auto auto minmax(0, 1fr) auto; + gap: 14px; +} + +.operations-dashboard-head { + align-items: flex-start; +} + +.operations-dashboard-filters { + display: grid; + grid-template-columns: repeat(5, minmax(115px, 0.7fr)) minmax(220px, 1.5fr) auto; + gap: 10px; + align-items: end; +} + +.operations-dashboard-filters label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 11px; +} + +.operations-dashboard-filters input, +.operations-dashboard-filters select { + width: 100%; +} + +.operations-dashboard-active-filters { + min-height: 17px; + font-size: 11px; +} + +.operations-dashboard-summary { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 9px; +} + +.operations-dashboard-stat { + min-width: 0; + display: grid; + gap: 3px; + padding: 12px 13px; + border: 1px solid var(--line); + border-radius: 10px; + background: #f8fafb; + color: var(--ink); + font: inherit; + text-align: left; +} + +.operations-dashboard-stat[data-operations-status] { + cursor: pointer; +} + +.operations-dashboard-stat[data-operations-status]:hover, +.operations-dashboard-stat[data-operations-status].is-active { + border-color: #78a99b; + background: #eef7f3; +} + +.operations-dashboard-stat[data-operations-status].is-active { + box-shadow: inset 0 0 0 1px #78a99b; +} + +.operations-dashboard-stat > span, +.operations-dashboard-stat > small { + color: var(--muted); + font-size: 10px; +} + +.operations-dashboard-stat > strong { + color: var(--ink); + font-size: 23px; + line-height: 1.05; +} + +.operations-dashboard-breakdowns { + min-width: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.operations-dashboard-breakdowns > section { + min-width: 0; + display: grid; + gap: 8px; + padding: 10px 11px; + border: 1px solid var(--line); + border-radius: 11px; + background: #fbfcfc; +} + +.operations-dashboard-breakdown-list { + min-width: 0; + display: flex; + gap: 7px; + overflow-x: auto; + padding-bottom: 2px; +} + +.operations-dashboard-breakdown-row { + min-width: 155px; + display: grid; + gap: 4px; + padding: 8px 9px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + color: var(--ink); + font: inherit; + text-align: left; +} + +.operations-dashboard-breakdown-row:hover { + border-color: #8eb5a8; + background: #f3f9f6; +} + +.operations-dashboard-breakdown-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 11px; +} + +.operations-dashboard-breakdown-heading b { + color: var(--accent); + font-size: 15px; +} + +.operations-dashboard-grid { + min-height: 0; + display: grid; + grid-template-columns: minmax(260px, 0.72fr) minmax(420px, 1.5fr); + gap: 12px; +} + +.operations-dashboard-column { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 9px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fbfcfc; +} + +.operations-dashboard-task-column { + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.operations-dashboard-section-head, +.operations-dashboard-row-heading { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.operations-dashboard-section-head h3 { + margin: 0; + font-size: 13px; +} + +.operations-dashboard-users, +.operations-dashboard-tasks { + min-height: 0; + display: grid; + align-content: start; + gap: 8px; + overflow: auto; +} + +.operations-dashboard-user-row, +.operations-dashboard-task-row { + width: 100%; + min-width: 0; + display: grid; + gap: 6px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 9px; + color: var(--ink); + background: #fff; + text-align: left; +} + +.operations-dashboard-user-row:hover, +.operations-dashboard-task-row:hover, +.operations-dashboard-task-row.is-selected { + border-color: #8eb5a8; + background: #f3f9f6; +} + +.operations-dashboard-user-metrics { + display: flex; + flex-wrap: wrap; + gap: 5px 12px; + color: var(--muted); + font-size: 11px; +} + +.operations-dashboard-user-metrics .is-error { + color: var(--bad); +} + +.operations-dashboard-task-row p { + margin: 0; +} + +.operations-dashboard-instruction { + display: -webkit-box; + overflow: hidden; + color: var(--ink); + font-size: 12px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.operations-dashboard-result { + display: -webkit-box; + overflow: hidden; + color: #356a5a; + font-size: 12px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.operations-dashboard-detail { + display: grid; + gap: 14px; +} + +.operations-dashboard-detail-body { + display: grid; + gap: 12px; +} + +.operations-dashboard-detail-section { + min-width: 0; + display: grid; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--line); +} + +.operations-dashboard-detail-section:first-child { + padding-top: 0; + border-top: 0; +} + +.operations-dashboard-detail-section h3 { + margin: 0; + font-size: 13px; +} + +.operations-dashboard-detail-facts { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; +} + +.operations-dashboard-original-input, +.operations-dashboard-input-item pre, +.operations-dashboard-result-detail { + max-height: 260px; + margin: 0; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.operations-dashboard-result-detail { + padding: 11px 12px; + border: 1px solid #bdd7cd; + border-radius: 8px; + background: #f3f9f6; + color: #285c4d; + font-family: inherit; + line-height: 1.55; +} + +.operations-dashboard-input-history, +.operations-dashboard-attachments { + display: grid; + gap: 8px; +} + +.operations-dashboard-input-item, +.operations-dashboard-attachment-row { + min-width: 0; + display: grid; + gap: 5px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fafbfb; +} + +@media (max-width: 1180px) { + .operations-dashboard-filters { + grid-template-columns: repeat(3, minmax(140px, 1fr)); + } + + .operations-dashboard-summary { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .operations-dashboard-detail-facts { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 820px) { + .operations-dashboard-grid, + .operations-dashboard-filters, + .operations-dashboard-breakdowns { + grid-template-columns: minmax(0, 1fr); + } + + .operations-dashboard-summary, + .operations-dashboard-detail-facts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .operations-dashboard-column { + max-height: none; + } +} + .history-page .workbench-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); } diff --git a/control-plane/README.md b/control-plane/README.md index 344e7f2..3ab68c4 100644 --- a/control-plane/README.md +++ b/control-plane/README.md @@ -5,17 +5,18 @@ ## 服务边界 - PostgreSQL 是任务、事件、幂等、审计和回查状态的唯一事实源。 -- 管理员使用原生账号登录;服务端会话使用 HttpOnly/Secure/SameSite Cookie。 +- 平台账号使用 `admin`(管理员)、`team_lead`(组长)和 `user`(普通用户)三种固定角色登录;服务端会话使用 HttpOnly/Secure/SameSite Cookie。管理员维护账号、角色、状态、密码重置、会话撤销和可执行任务类型;平台不提供组织或租户选择。 - 登录会话默认跨浏览器重启持续有效,不按空闲时间或绝对时长自动失效;用户退出、修改密码、账号停用或管理员强制撤销时由服务端立即撤销。 -- 业务页面通过 REST 创建任务;Agent 返回结构化结果后,管理员在选中任务上一次点击“确认并提交到 ERP 插件”,再通过 SSE 或轮询读取服务端状态。 +- 业务页面通过 REST 创建任务;Agent 返回结构化结果后,任务创建人在自己的任务上一次点击“确认并提交到 ERP 插件”,再通过 SSE 或轮询读取服务端状态。普通用户与组长的正常任务 API、SSE、附件和插件执行权限都只覆盖本人创建的人工任务;管理员可访问固定部署范围内的全部任务和 AgentBus/system 任务。 - 手工与 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 继续使用管理员控制的独立渠道边界,不映射为平台普通账号权限。 - 名单 route 创建后先进入 `awaiting_attachment`,只接收一份 `.xls/.xlsx` 14 列模板。附件在内存中失败关闭地规范化为 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 会话中工作;浏览器连接绑定该平台账号,`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` 持久化和写后回查;扩展后台重启后也不会重跑同一任务。本次双轨解析不修改插件执行契约或扩展版本。 @@ -23,8 +24,9 @@ - 微信桥接器把正文放在严格的 `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 的默认值。仍由完整旧环境变量托管的兼容渠道会自动重建,必须先移除环境配置并重启服务,才允许删除其数据库记录。 -- `/history` 支持逐条彻底删除,以及勾选当前页后批量删除。单条使用 `DELETE /api/tasks/:taskId`,批量使用 `POST /api/tasks/bulk-delete`(一次 1–100 个且不能重复);两者都要求管理员 mutation 会话、same-origin 与 CSRF 门禁。批量删除会在同一数据库事务中按组织锁定并核对全部目标,任一任务不存在或不属于当前组织时整批回滚;成功后任务、生命周期、尝试、会话、附件元数据和 AgentBus 回执按外键级联删除,task-scoped audit/outbox 行显式删除。OSS 附件对象在事务提交后使用已冻结的 storage key 逐一清理,清理异常写入服务日志。对已确认或正在插件流程中的任务,页面会明确警告:停止插件只是尽力而为,已经发生的 ERP 写入及已投递到外部渠道的副本不会因删除平台历史而撤回。 +- 单一部署范围可以维护多个“用户渠道”。渠道代表外部 AgentBus 用户身份,不等同于平台账号;管理员在独立根路径 `/channels` 的“AgentBus 渠道”目录中创建、停用、启用、轮换或删除渠道。删除会停止对应 listener、移除服务端保存的 key 和该渠道尚存的持久化回执;历史任务本体保留,其 `channel_id` 按数据库契约置空。每个渠道独立保存加密后的 AgentBus key,并建立独立 WebSocket listener;列表和日志都不会回显 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` 仅由完整的旧环境连接字段自动启用。 - `user_channels`、`tasks.channel_id` 和 `agentbus_deliveries` 共同保存入站归属、accepted 受理回执和最终 result 回执。回执以 `(channel_id, inbound_frame_id, delivery_kind)` 幂等,发送失败会重试,进程重启或 WebSocket 重连后仍会继续投递;因此不会因为超过原等待时长而丢掉最终回复。 - ERP 插件领取由组织级数据库锁和 FIFO confirmed 队列统一串行化:同一组织/同一 ERP 浏览器会话在任意时刻最多一个 ERP execution,其他任务留在服务端等待;已开始写入但结果不确定的任务会阻塞后续领取,直到人工回查收敛。 @@ -87,7 +89,7 @@ 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` 索引。原文、完整程序/AI 候选、名单 canonical 中间文本和人工说明使用字段加密保存;统计、审计和日志只使用代码、哈希、字段路径和计数。 +迁移 `013_business_parser_modes` 增加内部固定范围的路由设置、任务快照和加密的 `parse_decisions`;迁移 `014_task_input_attachments` 增加名单输入附件元数据、加密 canonical TSV 与 `awaiting_attachment` 索引;迁移 `015_account_roles_and_task_audit` 增加账号角色、强制改密、输入/附件操作者、任务归档和账号级幂等;迁移 `016_team_lead_operations_dashboard` 增加组长角色与人工指令看板索引;迁移 `017_user_business_route_authorizations` 增加逐账号业务白名单、授权人和乐观并发 revision。原文、完整程序/AI 候选、名单 canonical 中间文本和人工说明使用字段加密保存;统计、全局审计和运行日志不复制明文业务输入。 ## AgentBus Bot 接入 @@ -137,7 +139,7 @@ npm run data:retention npm run dev ``` -`npm run dev` 和 `npm start` 会先执行数据库迁移,再启动控制平面;直接运行 `control-plane/src/server.ts` 或构建后的 `server.js` 时,服务也会在启动前检查必需迁移 `014_task_input_attachments`,缺失时拒绝监听端口。`db:migrate` 和管理员初始化需要可连接的 PostgreSQL。开发机没有数据库时,可以运行 `npm run test:control-plane` 完成无数据库静态/健康烟测。 +`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` 完成无数据库静态/健康烟测。 `/health/ready` 同时检查 PostgreSQL 可用性和必需 schema 版本;迁移未完成时返回 503,并标明 `required_migration`,避免任务在数据库结构未升级时进入解析队列。 @@ -152,4 +154,4 @@ npm run dev 正式环境没有独立 staging。上线前必须完成离线测试、迁移预检查和迁移前备份;恢复检查使用 `infra/restore-check.sh` 指向一次性恢复数据库。 -生产容器中的清理命令为:`docker compose exec control-plane node .build/control-plane/src/retention.js`。当前生产模板默认 `DATA_RETENTION_ENABLED=false`,命令只会返回零删除结果,待正式保留期限确认后再启用。本地开发则使用上面的 `npm run data:retention`。 +生产容器中的保留策略命令为:`docker compose exec control-plane node .build/control-plane/src/retention.js`。当前生产模板默认 `DATA_RETENTION_ENABLED=false`;启用后,符合期限的终态任务只会转为归档,并清理已撤销或已过期超过 30 天的登录会话,不会物理删除任务或审计记录。本地开发则使用上面的 `npm run data:retention`。 diff --git a/control-plane/migrations/015_account_roles_and_task_audit.sql b/control-plane/migrations/015_account_roles_and_task_audit.sql new file mode 100644 index 0000000..7eb512f --- /dev/null +++ b/control-plane/migrations/015_account_roles_and_task_audit.sql @@ -0,0 +1,100 @@ +-- Fixed-scope account roles, audit attribution, and reversible task archive. +-- Existing accounts remain administrators; no organization/tenant UI is added. + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_role_check; + +ALTER TABLE users + ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'user')); + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS must_change_password boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS password_changed_at timestamptz NOT NULL DEFAULT now(); + +-- User-supplied idempotency keys are account-scoped. System/AgentBus keys +-- remain deployment-scoped through the NULL actor partial index. +ALTER TABLE tasks + DROP CONSTRAINT IF EXISTS tasks_organization_id_idempotency_key_key; + +ALTER TABLE idempotency_keys + DROP CONSTRAINT IF EXISTS idempotency_keys_organization_id_scope_idempotency_key_key; + +ALTER TABLE idempotency_keys + ADD COLUMN IF NOT EXISTS actor_user_id uuid REFERENCES users(id); + +UPDATE idempotency_keys i + SET actor_user_id = t.created_by + FROM tasks t + WHERE i.task_id = t.id + AND i.actor_user_id IS NULL + AND t.source = 'manual'; + +CREATE UNIQUE INDEX IF NOT EXISTS idempotency_keys_actor_unique_idx + ON idempotency_keys (organization_id, scope, idempotency_key, actor_user_id) + WHERE actor_user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idempotency_keys_system_unique_idx + ON idempotency_keys (organization_id, scope, idempotency_key) + WHERE actor_user_id IS NULL; + +ALTER TABLE agent_session_messages + ADD COLUMN IF NOT EXISTS actor_user_id uuid REFERENCES users(id), + ADD COLUMN IF NOT EXISTS input_source text; + +ALTER TABLE agent_session_messages + DROP CONSTRAINT IF EXISTS agent_session_messages_input_source_check; + +ALTER TABLE agent_session_messages + ADD CONSTRAINT agent_session_messages_input_source_check + CHECK (input_source IS NULL OR input_source IN ('manual', 'agentbus', 'reparse', 'system')); + +-- Only the initial input has a provable historical platform actor. Do not +-- invent actors for later turns that predate explicit attribution. +UPDATE agent_session_messages m + SET actor_user_id = t.created_by, + input_source = CASE WHEN t.source = 'agentbus' THEN 'agentbus' ELSE 'manual' END + FROM tasks t + WHERE m.task_id = t.id + AND m.role = 'user' + AND m.turn_no = 1 + AND (m.actor_user_id IS NULL OR m.input_source IS NULL); + +ALTER TABLE task_input_attachments + ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES users(id); + +UPDATE task_input_attachments a + SET created_by = t.created_by + FROM tasks t + WHERE a.task_id = t.id + AND a.created_by IS NULL + AND a.source = 'manual'; + +ALTER TABLE tasks + ADD COLUMN IF NOT EXISTS archived_at timestamptz, + ADD COLUMN IF NOT EXISTS archived_by uuid REFERENCES users(id), + ADD COLUMN IF NOT EXISTS archive_reason text; + +ALTER TABLE tasks + DROP CONSTRAINT IF EXISTS tasks_archive_reason_length_check; + +ALTER TABLE tasks + ADD CONSTRAINT tasks_archive_reason_length_check + CHECK (archive_reason IS NULL OR char_length(archive_reason) <= 500); + +CREATE INDEX IF NOT EXISTS users_org_role_active_idx + ON users (organization_id, role, is_active, username); + +CREATE INDEX IF NOT EXISTS tasks_owner_visible_created_idx + ON tasks (organization_id, created_by, created_at DESC, id DESC) + WHERE archived_at IS NULL; + +CREATE INDEX IF NOT EXISTS tasks_archive_created_idx + ON tasks (organization_id, archived_at DESC, created_at DESC, id DESC) + WHERE archived_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS agent_session_messages_actor_idx + ON agent_session_messages (organization_id, actor_user_id, created_at DESC) + WHERE actor_user_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS audit_events_actor_created_idx + ON audit_events (organization_id, actor_user_id, created_at DESC); diff --git a/control-plane/migrations/016_team_lead_operations_dashboard.sql b/control-plane/migrations/016_team_lead_operations_dashboard.sql new file mode 100644 index 0000000..c3fb505 --- /dev/null +++ b/control-plane/migrations/016_team_lead_operations_dashboard.sql @@ -0,0 +1,24 @@ +-- Add the fixed-scope team-lead role and read-only operations-dashboard indexes. +-- This remains a single-organization deployment; no tenant selector is added. + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_role_check; + +ALTER TABLE users + ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'team_lead', 'user')); + +CREATE INDEX IF NOT EXISTS tasks_operations_dashboard_created_idx + ON tasks (organization_id, created_at DESC, id DESC) + WHERE source = 'manual'; + +CREATE INDEX IF NOT EXISTS tasks_operations_dashboard_actor_idx + ON tasks (organization_id, created_by, created_at DESC, id DESC) + WHERE source = 'manual'; + +CREATE INDEX IF NOT EXISTS tasks_operations_dashboard_status_idx + ON tasks (organization_id, status, created_at DESC, id DESC) + WHERE source = 'manual'; + +CREATE INDEX IF NOT EXISTS tasks_operations_dashboard_business_idx + ON tasks (organization_id, business_route_id, created_at DESC, id DESC) + WHERE source = 'manual'; diff --git a/control-plane/migrations/017_user_business_route_authorizations.sql b/control-plane/migrations/017_user_business_route_authorizations.sql new file mode 100644 index 0000000..c621e5e --- /dev/null +++ b/control-plane/migrations/017_user_business_route_authorizations.sql @@ -0,0 +1,68 @@ +-- Administrator-managed allowlists for the 18 registered manual business routes. +-- Administrators always retain every route; team leads and ordinary users are +-- denied by default until an administrator grants explicit route IDs. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS business_authorization_revision integer NOT NULL DEFAULT 0; + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_business_authorization_revision_check; + +ALTER TABLE users + ADD CONSTRAINT users_business_authorization_revision_check + CHECK (business_authorization_revision >= 0); + +CREATE UNIQUE INDEX IF NOT EXISTS users_organization_id_id_authorization_idx + ON users (organization_id, id); + +CREATE TABLE IF NOT EXISTS user_business_route_authorizations ( + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + route_id text NOT NULL, + granted_by uuid REFERENCES users(id), + granted_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (organization_id, user_id, route_id), + CONSTRAINT user_business_route_authorizations_route_check CHECK (route_id IN ( + 'team_order_create', + 'team_order_batch_create', + 'shared_plan_create', + 'shared_child_order_create', + 'passenger_list_import_independent', + 'passenger_list_import_shared_child', + 'arrangement_guide_create', + 'arrangement_vehicle_create', + 'arrangement_hotel_create', + 'arrangement_transport_create', + 'arrangement_other_create', + 'order_update_shared_plan', + 'order_update_shared_child', + 'order_update_independent', + 'arrangement_hotel_update', + 'order_cancel', + 'order_restore', + 'confirmation_export' + )) +); + +CREATE INDEX IF NOT EXISTS user_business_route_authorizations_user_idx + ON user_business_route_authorizations (organization_id, user_id, route_id); + +CREATE INDEX IF NOT EXISTS user_business_route_authorizations_route_idx + ON user_business_route_authorizations (organization_id, route_id, user_id); + +ALTER TABLE user_business_route_authorizations + DROP CONSTRAINT IF EXISTS user_business_route_authorizations_user_scope_fkey; + +ALTER TABLE user_business_route_authorizations + ADD CONSTRAINT user_business_route_authorizations_user_scope_fkey + FOREIGN KEY (organization_id, user_id) + REFERENCES users (organization_id, id) + ON DELETE CASCADE; + +ALTER TABLE user_business_route_authorizations + DROP CONSTRAINT IF EXISTS user_business_route_authorizations_granter_scope_fkey; + +ALTER TABLE user_business_route_authorizations + ADD CONSTRAINT user_business_route_authorizations_granter_scope_fkey + FOREIGN KEY (organization_id, granted_by) + REFERENCES users (organization_id, id); diff --git a/control-plane/src/auth.ts b/control-plane/src/auth.ts index 8384c17..a3989f8 100644 --- a/control-plane/src/auth.ts +++ b/control-plane/src/auth.ts @@ -2,17 +2,38 @@ import argon2 from 'argon2'; import type { AppConfig } from './config.js'; import { getPool, withTransaction } from './db.js'; import { hashToken, randomToken, sameTokenHash } from './crypto.js'; +import { + BUSINESS_ROUTES, + businessRouteById, + type BusinessRouteId +} from './business-routes.js'; import { diagnosticMetadataKeys, noopDiagnosticLogger, type DiagnosticLogger } from './diagnostics.js'; +export type AuthRole = 'admin' | 'team_lead' | 'user'; + export interface AuthUser { id: string; organizationId: string; username: string; - role: 'admin'; + role: AuthRole; + mustChangePassword: boolean; +} + +export interface PublicAccount { + id: string; + username: string; + role: AuthRole; + is_active: boolean; + must_change_password: boolean; + authorized_business_route_ids: BusinessRouteId[]; + business_authorization_revision: number; + last_login_at: string | null; + created_at: string; + updated_at: string; } export interface AuthSession { @@ -43,15 +64,99 @@ function normalizeUsername(value: string): string { return String(value || '').trim().toLowerCase(); } +function validateUsername(value: string): string { + const normalized = normalizeUsername(value); + if (!normalized || normalized.length > 160) { + throw new AuthError('username_invalid', '账号必须为 1—160 个字符。', 400); + } + return normalized; +} + +function validatePassword(value: string): string { + const password = String(value || ''); + if (password.length < 12 || password.length > 512) { + throw new AuthError('password_invalid', '密码必须为 12—512 个字符。', 400); + } + return password; +} + +function normalizeRole(value: unknown): AuthRole { + if (value === 'admin' || value === 'team_lead') return value; + return 'user'; +} + +function isoOrNull(value: unknown): string | null { + if (!value) return null; + const date = new Date(String(value)); + return Number.isFinite(date.getTime()) ? date.toISOString() : null; +} + +const ALL_BUSINESS_ROUTE_IDS = BUSINESS_ROUTES.map((route) => route.routeId); +const BUSINESS_ROUTE_DISPLAY_ORDER = new Map( + ALL_BUSINESS_ROUTE_IDS.map((routeId, index) => [routeId, index]) +); + +function normalizeBusinessRouteIds(values: readonly unknown[] | undefined): BusinessRouteId[] { + const normalized = [...new Set((values || []).map((value) => String(value || '').trim()).filter(Boolean))]; + const invalid = normalized.find((routeId) => !businessRouteById(routeId)); + if (invalid) throw new AuthError('business_route_invalid', `业务类型 ${invalid} 不存在。`, 400); + return (normalized as BusinessRouteId[]).sort((left, right) => ( + (BUSINESS_ROUTE_DISPLAY_ORDER.get(left) ?? Number.MAX_SAFE_INTEGER) + - (BUSINESS_ROUTE_DISPLAY_ORDER.get(right) ?? Number.MAX_SAFE_INTEGER) + )); +} + function mapUser(row: Record): AuthUser { return { id: String(row.id), organizationId: String(row.organization_id), username: String(row.username), - role: 'admin' + role: normalizeRole(row.role), + mustChangePassword: row.must_change_password === true || String(row.must_change_password) === 'true' }; } +function mapAccount(row: Record): PublicAccount { + const role = normalizeRole(row.role); + const storedRouteIds = normalizeBusinessRouteIds( + Array.isArray(row.authorized_business_route_ids) ? row.authorized_business_route_ids : [] + ); + return { + id: String(row.id), + username: String(row.username), + role, + is_active: row.is_active === true || String(row.is_active) === 'true', + must_change_password: row.must_change_password === true || String(row.must_change_password) === 'true', + authorized_business_route_ids: role === 'admin' ? [...ALL_BUSINESS_ROUTE_IDS] : storedRouteIds, + business_authorization_revision: Math.max(0, Number(row.business_authorization_revision || 0)), + last_login_at: isoOrNull(row.last_login_at), + created_at: isoOrNull(row.created_at) || new Date(0).toISOString(), + updated_at: isoOrNull(row.updated_at) || new Date(0).toISOString() + }; +} + +async function loadPublicAccount( + client: import('pg').PoolClient, + organizationId: string, + userId: string +): Promise { + const result = await client.query( + `SELECT u.id, u.username, u.role, u.is_active, u.must_change_password, + u.business_authorization_revision, + u.last_login_at, u.created_at, u.updated_at, + COALESCE(ARRAY( + SELECT route_grant.route_id + FROM user_business_route_authorizations route_grant + WHERE route_grant.organization_id = u.organization_id + AND route_grant.user_id = u.id + ), ARRAY[]::text[]) AS authorized_business_route_ids + FROM users u + WHERE u.organization_id = $1 AND u.id = $2`, + [organizationId, userId] + ); + return result.rowCount ? mapAccount(result.rows[0] as Record) : null; +} + export class AuthService { private readonly dummyHashPromise = argon2.hash('ltjt-dummy-password', { type: argon2.argon2id, @@ -93,11 +198,10 @@ export class AuthService { } async bootstrapAdmin(username: string, password: string, { force = false } = {}): Promise { - const normalized = normalizeUsername(username); - if (!normalized || normalized.length > 160) throw new Error('username must be 1-160 characters.'); - if (!password || password.length < 12) throw new Error('password must be at least 12 characters.'); + const normalized = validateUsername(username); + const validatedPassword = validatePassword(password); const organization = await this.ensureOrganization(); - const passwordHash = await argon2.hash(password, { type: argon2.argon2id }); + const passwordHash = await argon2.hash(validatedPassword, { type: argon2.argon2id }); return withTransaction(this.config, async (client) => { const existing = await client.query( 'SELECT id FROM users WHERE organization_id = $1 AND username = $2 FOR UPDATE', @@ -109,16 +213,17 @@ export class AuthService { const result = existing.rowCount ? await client.query( `UPDATE users - SET password_hash = $1, is_active = true, failed_login_count = 0, - locked_until = NULL, updated_at = now() + SET password_hash = $1, role = 'admin', 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`, + RETURNING id, organization_id, username, role, must_change_password`, [passwordHash, existing.rows[0].id] ) : await client.query( - `INSERT INTO users (organization_id, username, password_hash) - VALUES ($1, $2, $3) - RETURNING id, organization_id, username`, + `INSERT INTO users (organization_id, username, password_hash, role, must_change_password) + VALUES ($1, $2, $3, 'admin', false) + RETURNING id, organization_id, username, role, must_change_password`, [organization.id, normalized, passwordHash] ); const user = mapUser(result.rows[0]); @@ -137,7 +242,7 @@ export class AuthService { const pool = getPool(this.config); const lookup = await pool.query( `SELECT id, organization_id, username, password_hash, role, is_active, - failed_login_count, locked_until + must_change_password, failed_login_count, locked_until FROM users WHERE organization_id = (SELECT id FROM organizations WHERE slug = $1) AND username = $2`, @@ -179,12 +284,12 @@ export class AuthService { } async resetPassword(username: string, password: string): Promise { - const normalized = normalizeUsername(username); - if (!password || password.length < 12) throw new Error('password must be at least 12 characters.'); - const passwordHash = await argon2.hash(password, { type: argon2.argon2id }); + const normalized = validateUsername(username); + const passwordHash = await argon2.hash(validatePassword(password), { type: argon2.argon2id }); const result = await getPool(this.config).query( `UPDATE users - SET password_hash = $1, failed_login_count = 0, locked_until = NULL, updated_at = now() + SET password_hash = $1, must_change_password = false, password_changed_at = now(), + failed_login_count = 0, locked_until = NULL, updated_at = now() WHERE organization_id = (SELECT id FROM organizations WHERE slug = $2) AND username = $3 RETURNING id`, @@ -197,6 +302,345 @@ export class AuthService { ); } + private requireAdmin(actor: AuthUser): void { + if (actor.role !== 'admin') throw new AuthError('admin_required', '需要管理员权限。', 403); + } + + private async accountAudit( + client: import('pg').PoolClient, + actor: AuthUser, + eventType: string, + targetUserId: string, + requestId: string, + metadata: Record = {} + ): Promise { + await client.query( + `INSERT INTO audit_events + (organization_id, actor_user_id, event_type, entity_type, entity_id, request_id, metadata) + VALUES ($1, $2, $3, 'user', $4, $5, $6)`, + [actor.organizationId, actor.id, eventType, targetUserId, requestId, metadata] + ); + this.log({ + diagnostic_event: 'audit.event.staged', + diagnostic_stage: 'account_audit', + request_id: requestId, + domain_event: eventType, + entity_type: 'user', + actor_present: true, + metadata_keys: diagnosticMetadataKeys(metadata) + }, 'account audit event persisted'); + } + + 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, u.must_change_password, + u.business_authorization_revision, + u.last_login_at, u.created_at, u.updated_at, + COALESCE(ARRAY( + SELECT route_grant.route_id + FROM user_business_route_authorizations route_grant + WHERE route_grant.organization_id = u.organization_id + AND route_grant.user_id = u.id + ), ARRAY[]::text[]) AS authorized_business_route_ids + FROM users u + WHERE u.organization_id = $1 + ORDER BY u.username ASC`, + [actor.organizationId] + ); + return (result.rows as Record[]).map(mapAccount); + } + + async createAccount( + actor: AuthUser, + input: { + username: string; + password: string; + role: AuthRole; + mustChangePassword?: boolean; + businessRouteIds?: readonly string[]; + }, + requestId: string + ): Promise { + this.requireAdmin(actor); + const username = validateUsername(input.username); + const passwordHash = await argon2.hash(validatePassword(input.password), { type: argon2.argon2id }); + const role = normalizeRole(input.role); + const mustChangePassword = input.mustChangePassword !== false; + const businessRouteIds = role === 'admin' ? [] : normalizeBusinessRouteIds(input.businessRouteIds); + return withTransaction(this.config, async (client) => { + await client.query( + `SELECT pg_advisory_xact_lock(hashtextextended($1::text || ':' || $2::text, 0))`, + [actor.organizationId, username] + ); + const existing = await client.query( + 'SELECT id FROM users WHERE organization_id = $1 AND username = $2', + [actor.organizationId, username] + ); + if (existing.rowCount) throw new AuthError('account_exists', '该账号已存在。', 409); + const created = await client.query( + `INSERT INTO users + (organization_id, username, password_hash, role, must_change_password, password_changed_at) + VALUES ($1, $2, $3, $4, $5, now()) + RETURNING id`, + [actor.organizationId, username, passwordHash, role, mustChangePassword] + ); + const accountId = String(created.rows[0].id); + if (businessRouteIds.length) { + await client.query( + `INSERT INTO user_business_route_authorizations + (organization_id, user_id, route_id, granted_by) + SELECT $1, $2, route_id, $3 + FROM unnest($4::text[]) AS route_id`, + [actor.organizationId, accountId, actor.id, businessRouteIds] + ); + } + const account = await loadPublicAccount(client, actor.organizationId, accountId); + if (!account) throw new AuthError('account_not_found', '账号创建后未能读取。', 500); + await this.accountAudit(client, actor, 'account.created', account.id, requestId, { + role, + must_change_password: mustChangePassword, + authorized_business_route_ids: account.authorized_business_route_ids + }); + return account; + }); + } + + async updateAccount( + actor: AuthUser, + targetUserId: string, + input: { role?: AuthRole; isActive?: boolean }, + requestId: string + ): Promise { + this.requireAdmin(actor); + if (input.role === undefined && input.isActive === undefined) { + throw new AuthError('account_update_empty', '没有需要更新的账号字段。', 400); + } + return withTransaction(this.config, async (client) => { + const target = await client.query( + `SELECT id, username, role, is_active, must_change_password, + last_login_at, created_at, updated_at + FROM users + WHERE organization_id = $1 AND id = $2 + FOR UPDATE`, + [actor.organizationId, targetUserId] + ); + if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404); + 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 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); + } + if (removesActiveAdmin) { + const activeAdmins = await client.query( + `SELECT id FROM users + WHERE organization_id = $1 AND role = 'admin' AND is_active = true + FOR UPDATE`, + [actor.organizationId] + ); + if (activeAdmins.rows.filter((row: Record) => String(row.id) !== before.id).length === 0) { + throw new AuthError('last_admin_protected', '必须至少保留一个有效管理员账号。', 409); + } + } + const updated = await client.query( + `UPDATE users + SET role = $1, is_active = $2, updated_at = now() + WHERE organization_id = $3 AND id = $4 + RETURNING id`, + [role, isActive, actor.organizationId, before.id] + ); + if (before.role !== role || before.is_active !== isActive) { + await client.query( + 'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL', + [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 + }); + const account = await loadPublicAccount(client, actor.organizationId, String(updated.rows[0].id)); + if (!account) throw new AuthError('account_not_found', '账号更新后未能读取。', 500); + return account; + }); + } + + async setBusinessRouteAuthorizations( + actor: AuthUser, + targetUserId: string, + routeIds: readonly string[], + expectedRevision: number, + requestId: string + ): Promise { + this.requireAdmin(actor); + const authorizedRouteIds = normalizeBusinessRouteIds(routeIds); + return withTransaction(this.config, async (client) => { + const target = await client.query( + `SELECT id, role, business_authorization_revision + FROM users + WHERE organization_id = $1 AND id = $2 + FOR UPDATE`, + [actor.organizationId, targetUserId] + ); + if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404); + const row = target.rows[0] as Record; + if (normalizeRole(row.role) === 'admin') { + throw new AuthError('admin_business_authorization_fixed', '管理员固定拥有全部业务权限,无需单独授权。', 409); + } + const currentRevision = Math.max(0, Number(row.business_authorization_revision || 0)); + if (currentRevision !== expectedRevision) { + throw new AuthError('business_authorization_revision_conflict', '该账号的业务权限已被其他管理员修改,请刷新后重试。', 409); + } + const previous = await client.query( + `SELECT route_id + FROM user_business_route_authorizations + WHERE organization_id = $1 AND user_id = $2`, + [actor.organizationId, targetUserId] + ); + const previousRouteIds = normalizeBusinessRouteIds( + (previous.rows as Record[]).map((item) => item.route_id) + ); + await client.query( + `DELETE FROM user_business_route_authorizations + WHERE organization_id = $1 AND user_id = $2`, + [actor.organizationId, targetUserId] + ); + if (authorizedRouteIds.length) { + await client.query( + `INSERT INTO user_business_route_authorizations + (organization_id, user_id, route_id, granted_by) + SELECT $1, $2, route_id, $3 + FROM unnest($4::text[]) AS route_id`, + [actor.organizationId, targetUserId, actor.id, authorizedRouteIds] + ); + } + await client.query( + `UPDATE users + SET business_authorization_revision = business_authorization_revision + 1, + updated_at = now() + WHERE organization_id = $1 AND id = $2`, + [actor.organizationId, targetUserId] + ); + await this.accountAudit( + client, + actor, + 'account.business_authorizations_updated', + targetUserId, + requestId, + { + previous_business_route_ids: previousRouteIds, + business_route_ids: authorizedRouteIds, + previous_revision: currentRevision, + revision: currentRevision + 1 + } + ); + const account = await loadPublicAccount(client, actor.organizationId, targetUserId); + if (!account) throw new AuthError('account_not_found', '账号权限更新后未能读取。', 500); + return account; + }); + } + + async resetAccountPassword( + actor: AuthUser, + targetUserId: string, + password: string, + mustChangePassword: boolean, + requestId: string + ): Promise { + this.requireAdmin(actor); + const passwordHash = await argon2.hash(validatePassword(password), { type: argon2.argon2id }); + await withTransaction(this.config, async (client) => { + const target = await client.query( + 'SELECT id FROM users WHERE organization_id = $1 AND id = $2 FOR UPDATE', + [actor.organizationId, targetUserId] + ); + if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404); + await client.query( + `UPDATE users + SET password_hash = $1, must_change_password = $2, + password_changed_at = now(), failed_login_count = 0, + locked_until = NULL, updated_at = now() + WHERE id = $3`, + [passwordHash, mustChangePassword, targetUserId] + ); + const revoked = await client.query( + 'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL', + [targetUserId] + ); + await this.accountAudit(client, actor, 'account.password_reset', targetUserId, requestId, { + must_change_password: mustChangePassword, + sessions_revoked: revoked.rowCount || 0 + }); + }); + } + + async revokeAccountSessions(actor: AuthUser, targetUserId: string, requestId: string): Promise { + this.requireAdmin(actor); + return withTransaction(this.config, async (client) => { + const target = await client.query( + 'SELECT id FROM users WHERE organization_id = $1 AND id = $2 FOR UPDATE', + [actor.organizationId, targetUserId] + ); + if (!target.rowCount) throw new AuthError('account_not_found', '账号不存在。', 404); + const revoked = await client.query( + 'UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL', + [targetUserId] + ); + const count = revoked.rowCount || 0; + await this.accountAudit(client, actor, 'account.sessions_revoked', targetUserId, requestId, { + sessions_revoked: count + }); + return count; + }); + } + + async changeOwnPassword( + session: ActiveSession, + currentPassword: string, + newPassword: string, + requestId: string + ): Promise { + const validatedNewPassword = validatePassword(newPassword); + const newHash = await argon2.hash(validatedNewPassword, { type: argon2.argon2id }); + await withTransaction(this.config, async (client) => { + const target = await client.query( + `SELECT password_hash, is_active + FROM users + WHERE organization_id = $1 AND id = $2 + FOR UPDATE`, + [session.user.organizationId, session.user.id] + ); + const row = target.rows[0] as Record | undefined; + if (!row || row.is_active === false || !(await argon2.verify(String(row.password_hash), currentPassword || ''))) { + throw new AuthError('current_password_invalid', '当前密码不正确。', 403); + } + if (await argon2.verify(String(row.password_hash), validatedNewPassword)) { + throw new AuthError('password_unchanged', '新密码不能与当前密码相同。', 409); + } + await client.query( + `UPDATE users + SET password_hash = $1, must_change_password = false, + password_changed_at = now(), failed_login_count = 0, + locked_until = NULL, updated_at = now() + WHERE id = $2`, + [newHash, session.user.id] + ); + const revoked = await client.query( + `UPDATE sessions SET revoked_at = now() + WHERE user_id = $1 AND id <> $2 AND revoked_at IS NULL`, + [session.user.id, session.id] + ); + await this.accountAudit(client, session.user, 'account.password_changed', session.user.id, requestId, { + other_sessions_revoked: revoked.rowCount || 0 + }); + }); + } + async createSession(user: AuthUser, ipAddress: string, userAgent: string): Promise { const token = randomToken(32); const csrfToken = randomToken(24); @@ -213,7 +657,8 @@ 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.must_change_password FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 @@ -236,7 +681,8 @@ export class AuthService { id: row.id, organization_id: row.organization_id, username: row.username, - role: row.role + role: row.role, + must_change_password: row.must_change_password }) }; } diff --git a/control-plane/src/db.ts b/control-plane/src/db.ts index 9dd8ea6..f5d952e 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 = '014_task_input_attachments'; +export const REQUIRED_SCHEMA_VERSION = '017_user_business_route_authorizations'; export interface DatabaseReadiness { ready: boolean; diff --git a/control-plane/src/retention.ts b/control-plane/src/retention.ts index 106653c..6553e33 100644 --- a/control-plane/src/retention.ts +++ b/control-plane/src/retention.ts @@ -2,21 +2,24 @@ import { loadConfig } from './config.js'; import { closePool, withTransaction } from './db.js'; import { writeEmergencyDiagnostic } from './diagnostics.js'; -export async function runRetention(): Promise<{ deletedTasks: number; deletedAuditEvents: number; deletedSessions: number }> { +export async function runRetention(): Promise<{ + archivedTasks: number; + deletedTasks: number; + deletedAuditEvents: number; + deletedSessions: number; +}> { const config = loadConfig(); if (!config.DATA_RETENTION_ENABLED) { - return { deletedTasks: 0, deletedAuditEvents: 0, deletedSessions: 0 }; + return { archivedTasks: 0, deletedTasks: 0, deletedAuditEvents: 0, deletedSessions: 0 }; } return withTransaction(config, async (client) => { - const deletedTasks = await client.query( - `DELETE FROM tasks + const archivedTasks = await client.query( + `UPDATE tasks + SET archived_at = now(), archived_by = NULL, + archive_reason = 'retention_policy', updated_at = now() WHERE created_at < now() - ($1 * interval '1 day') - AND status IN ('completed', 'cancelled', 'parse_failed')`, - [config.DATA_RETENTION_DAYS] - ); - const deletedAuditEvents = await client.query( - `DELETE FROM audit_events - WHERE created_at < now() - (($1 * 2) * interval '1 day')`, + AND status IN ('completed', 'cancelled', 'parse_failed') + AND archived_at IS NULL`, [config.DATA_RETENTION_DAYS] ); const deletedSessions = await client.query( @@ -26,8 +29,9 @@ export async function runRetention(): Promise<{ deletedTasks: number; deletedAud [] ); return { - deletedTasks: deletedTasks.rowCount || 0, - deletedAuditEvents: deletedAuditEvents.rowCount || 0, + archivedTasks: archivedTasks.rowCount || 0, + deletedTasks: 0, + deletedAuditEvents: 0, deletedSessions: deletedSessions.rowCount || 0 }; }); diff --git a/control-plane/src/server.ts b/control-plane/src/server.ts index 23da793..9c6c2aa 100644 --- a/control-plane/src/server.ts +++ b/control-plane/src/server.ts @@ -21,12 +21,15 @@ import { import { TaskError, TaskService, + canAccessTask, + canViewOperationsDashboard, type ParseDecisionInput, type ParseTaskClaim, type TaskContext, type TaskEvent } from './task-service.js'; import { ParserOrchestrator, type AiParser as ExternalParser } from './parser-orchestrator.js'; +import { BUSINESS_ROUTES, businessRouteById } from './business-routes.js'; import { decodeInlineInputAttachment, InputAttachmentError, @@ -51,6 +54,42 @@ const loginSchema = z.object({ password: z.string().min(1).max(512) }); +const changePasswordSchema = z.object({ + current_password: z.string().min(1).max(512), + new_password: z.string().min(12).max(512) +}); + +const accountCreateSchema = z.object({ + username: z.string().min(1).max(160), + password: z.string().min(12).max(512), + role: z.enum(['admin', 'team_lead', 'user']).default('user'), + must_change_password: z.boolean().default(true), + business_route_ids: z.array( + z.string().trim().refine((routeId) => Boolean(businessRouteById(routeId)), '业务类型不存在。') + ).max(BUSINESS_ROUTES.length).default([]) + .refine((routeIds) => new Set(routeIds).size === routeIds.length, '业务类型不能重复。') +}); + +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, { + message: '至少提供一个账号更新字段。' +}); + +const accountPasswordResetSchema = z.object({ + password: z.string().min(12).max(512), + must_change_password: z.boolean().default(true) +}); + +const accountBusinessAuthorizationsSchema = z.object({ + business_route_ids: z.array( + z.string().trim().refine((routeId) => Boolean(businessRouteById(routeId)), '业务类型不存在。') + ).max(BUSINESS_ROUTES.length) + .refine((routeIds) => new Set(routeIds).size === routeIds.length, '业务类型不能重复。'), + expected_revision: z.number().int().min(0) +}); + const encodedInputAttachmentSchema = z.object({ name: z.string().min(1).max(200), content_type: z.string().max(200).optional(), @@ -118,18 +157,41 @@ const listTasksQuerySchema = z.object({ search: z.string().max(200).optional(), 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'), include_total: z.preprocess( (value) => value === undefined ? true : String(value).toLowerCase() !== 'false', z.boolean() ).default(true) }); const taskBulkDeleteSchema = z.object({ - task_ids: z.array(z.string().trim().min(1).max(200)).min(1).max(100) + task_ids: z.array(z.string().trim().min(1).max(200)).min(1).max(100), + reason: z.string().trim().max(500).optional() }).refine( (body) => new Set(body.task_ids).size === body.task_ids.length, { message: '任务编号不能重复。', path: ['task_ids'] } ); +const taskArchiveSchema = z.object({ reason: z.string().trim().max(500).optional() }); + +const auditQuerySchema = z.object({ + event_type: z.string().trim().max(200).optional(), + actor_user_id: z.string().uuid().optional(), + entity_type: z.string().trim().max(120).optional(), + limit: z.coerce.number().int().min(1).max(200).default(100), + offset: z.coerce.number().int().min(0).max(1_000_000).default(0) +}); + +const operationsDashboardQuerySchema = z.object({ + from: z.string().datetime({ offset: true }).optional(), + to: z.string().datetime({ offset: true }).optional(), + actor_user_id: z.string().uuid().optional(), + business_route_id: z.string().trim().max(120).optional(), + status: z.enum(['all', 'active', 'completed', 'attention', 'failed', 'cancelled', 'archived']).default('all'), + search: z.string().trim().max(200).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).max(1_000_000).default(0) +}); + const LOG_REDACTION_PATHS = [ 'req.headers.authorization', 'req.headers.cookie', @@ -302,7 +364,7 @@ function publicUser(session: ActiveSession) { id: session.user.id, username: session.user.username, role: session.user.role, - organization_id: session.user.organizationId + must_change_password: session.user.mustChangePassword }; } @@ -402,6 +464,21 @@ export async function buildServer({ return reply.sendFile('index.html'); }); + app.get('/accounts', async (_request, reply) => { + reply.header('Cache-Control', 'no-store'); + return reply.sendFile('index.html'); + }); + + app.get('/audit', async (_request, reply) => { + reply.header('Cache-Control', 'no-store'); + return reply.sendFile('index.html'); + }); + + app.get('/operations-dashboard', async (_request, reply) => { + reply.header('Cache-Control', 'no-store'); + return reply.sendFile('index.html'); + }); + app.addHook('onRequest', async (request, reply) => { if (request.url.startsWith('/api/') || request.url.startsWith('/health/')) return; const redirectUrl = canonicalStaticRedirect(config, request); @@ -439,14 +516,35 @@ export async function buildServer({ return session; }; + const getReadySession = async (request: FastifyRequest): Promise => { + const session = await getSession(request); + if (session.user.mustChangePassword) { + throw new AuthError('password_change_required', '首次登录或密码重置后必须先修改密码。', 403); + } + return session; + }; + + const requireAdmin = (session: ActiveSession): ActiveSession => { + if (session.user.role !== 'admin') throw new AuthError('admin_required', '需要管理员权限。', 403); + return session; + }; + + const requireLeadership = (session: ActiveSession): ActiveSession => { + if (!canViewOperationsDashboard(session.user.role)) { + throw new AuthError('leadership_required', '需要组长或管理员权限。', 403); + } + return session; + }; + const contextFor = (session: ActiveSession, request: FastifyRequest): TaskContext => ({ organizationId: session.user.organizationId, userId: session.user.id, requestId: requestId(request), + role: session.user.role, source: 'manual' }); - const requireMutationSession = async (request: FastifyRequest): Promise => { + const requireAuthenticatedMutationSession = async (request: FastifyRequest): Promise => { requireSameOrigin(config, request); const session = await getSession(request); const csrf = String(request.headers['x-csrf-token'] || ''); @@ -456,6 +554,26 @@ export async function buildServer({ return session; }; + const requireMutationSession = async (request: FastifyRequest): Promise => { + const session = await requireAuthenticatedMutationSession(request); + if (session.user.mustChangePassword) { + throw new AuthError('password_change_required', '首次登录或密码重置后必须先修改密码。', 403); + } + return session; + }; + + const requireAdminSession = async (request: FastifyRequest): Promise => ( + requireAdmin(await getReadySession(request)) + ); + + const requireAdminMutationSession = async (request: FastifyRequest): Promise => ( + requireAdmin(await requireMutationSession(request)) + ); + + const requireLeadershipSession = async (request: FastifyRequest): Promise => ( + requireLeadership(await getReadySession(request)) + ); + async function persistParseOutcome( claim: ParseTaskClaim, result: unknown, @@ -834,13 +952,97 @@ export async function buildServer({ return { ok: true, csrf_token: await auth.rotateCsrf(session.id) }; }); + app.put('/api/auth/password', async (request) => { + const session = await requireAuthenticatedMutationSession(request); + const body = changePasswordSchema.parse(request.body); + await auth.changeOwnPassword(session, body.current_password, body.new_password, requestId(request)); + return { ok: true, password_changed: true }; + }); + + app.get('/api/accounts', async (request) => { + const session = await requireAdminSession(request); + return { + ok: true, + accounts: await auth.listAccounts(session.user), + task_types: BUSINESS_ROUTES.map((route, displayOrder) => ({ + route_id: route.routeId, + directive: route.directive, + action: route.action, + display_order: displayOrder + 1 + })) + }; + }); + + app.post('/api/accounts', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (request) => { + const session = await requireAdminMutationSession(request); + const body = accountCreateSchema.parse(request.body); + const account = await auth.createAccount(session.user, { + username: body.username, + password: body.password, + role: body.role, + mustChangePassword: body.must_change_password, + businessRouteIds: body.business_route_ids + }, requestId(request)); + return { ok: true, account }; + }); + + app.patch('/api/accounts/:userId', async (request) => { + const session = await requireAdminMutationSession(request); + const params = request.params as { userId: string }; + const userId = z.string().uuid().parse(params.userId); + const body = accountUpdateSchema.parse(request.body); + const account = await auth.updateAccount(session.user, userId, { + role: body.role, + isActive: body.is_active + }, requestId(request)); + return { ok: true, account }; + }); + + app.put('/api/accounts/:userId/business-authorizations', async (request) => { + const session = await requireAdminMutationSession(request); + const params = request.params as { userId: string }; + const userId = z.string().uuid().parse(params.userId); + const body = accountBusinessAuthorizationsSchema.parse(request.body); + const account = await auth.setBusinessRouteAuthorizations( + session.user, + userId, + body.business_route_ids, + body.expected_revision, + requestId(request) + ); + return { ok: true, account }; + }); + + app.post('/api/accounts/:userId/reset-password', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (request) => { + const session = await requireAdminMutationSession(request); + const params = request.params as { userId: string }; + const userId = z.string().uuid().parse(params.userId); + const body = accountPasswordResetSchema.parse(request.body); + await auth.resetAccountPassword( + session.user, + userId, + body.password, + body.must_change_password, + requestId(request) + ); + return { ok: true, password_reset: true, sessions_revoked: true }; + }); + + app.post('/api/accounts/:userId/revoke-sessions', async (request) => { + const session = await requireAdminMutationSession(request); + const params = request.params as { userId: string }; + const userId = z.string().uuid().parse(params.userId); + const revoked = await auth.revokeAccountSessions(session.user, userId, requestId(request)); + return { ok: true, sessions_revoked: revoked }; + }); + app.get('/api/settings/automation', async (request) => { - const session = await getSession(request); + const session = await requireAdminSession(request); return { ok: true, settings: await tasks.getAutomationSettings(session.user.organizationId) }; }); app.put('/api/settings/automation', async (request) => { - const session = await requireMutationSession(request); + const session = await requireAdminMutationSession(request); const body = automationSettingsSchema.parse(request.body); return { ok: true, @@ -849,12 +1051,12 @@ export async function buildServer({ }); app.get('/api/settings/parser-routing', async (request) => { - const session = await getSession(request); + const session = await requireAdminSession(request); return { ok: true, routes: await tasks.getParserRoutingSettings(session.user.organizationId) }; }); app.put('/api/settings/parser-routing/:routeId', async (request) => { - const session = await requireMutationSession(request); + const session = await requireAdminMutationSession(request); const routeId = String((request.params as { routeId?: string }).routeId || ''); const body = parserRoutingUpdateSchema.parse(request.body); return { @@ -869,7 +1071,7 @@ export async function buildServer({ }); app.post('/api/settings/parser-routing/emergency-ai', async (request) => { - const session = await requireMutationSession(request); + const session = await requireAdminMutationSession(request); const body = parserEmergencyAiSchema.parse(request.body); return { ok: true, @@ -878,7 +1080,7 @@ export async function buildServer({ }); app.post('/api/tasks/:taskId/reparse', async (request) => { - const session = await requireMutationSession(request); + const session = await requireAdminMutationSession(request); const taskId = String((request.params as { taskId?: string }).taskId || ''); const body = parserReparseSchema.parse(request.body); const task = await tasks.reparseTaskWithAi(contextFor(session, request), taskId, body.reason); @@ -890,7 +1092,7 @@ export async function buildServer({ }); app.put('/api/parser-decisions/:decisionId/review', async (request) => { - const session = await requireMutationSession(request); + const session = await requireAdminMutationSession(request); const decisionId = String((request.params as { decisionId?: string }).decisionId || ''); const body = parserDecisionReviewSchema.parse(request.body); return { @@ -902,7 +1104,7 @@ export async function buildServer({ }); app.get('/api/channels', async (request) => { - const session = await getSession(request); + const session = await requireAdminSession(request); const channels = await channelService.list(session.user.organizationId); return { ok: true, @@ -911,7 +1113,7 @@ export async function buildServer({ }); app.post('/api/channels', async (request) => { - const session = await requireMutationSession(request); + const session = await requireAdminMutationSession(request); const body = channelCreateSchema.parse(request.body); const channel = await channelService.create(contextFor(session, request), { displayName: body.display_name, @@ -925,7 +1127,7 @@ export async function buildServer({ }); app.patch('/api/channels/:channelId', async (request) => { - const session = await requireMutationSession(request); + const session = await requireAdminMutationSession(request); const params = request.params as { channelId: string }; const body = channelUpdateSchema.parse(request.body); const channel = await channelService.update(contextFor(session, request), params.channelId, { @@ -939,7 +1141,7 @@ export async function buildServer({ }); app.post('/api/channels/:channelId/rotate-key', async (request) => { - const session = await requireMutationSession(request); + const session = await requireAdminMutationSession(request); const params = request.params as { channelId: string }; const body = channelRotateKeySchema.parse(request.body); const channel = await channelService.rotateKey( @@ -952,7 +1154,7 @@ export async function buildServer({ }); app.delete('/api/channels/:channelId', async (request) => { - const session = await requireMutationSession(request); + const session = await requireAdminMutationSession(request); const params = request.params as { channelId: string }; const result = await channelService.delete(contextFor(session, request), params.channelId); await agentBus?.reload(); @@ -961,23 +1163,25 @@ export async function buildServer({ app.post('/api/auth/logout', async (request, reply) => { setAuthNoStore(reply); - const session = await requireMutationSession(request); + const session = await requireAuthenticatedMutationSession(request); await auth.revokeSession(request.cookies[config.SESSION_COOKIE_NAME]); reply.clearCookie(config.SESSION_COOKIE_NAME, { path: '/' }); await auth.recordAudit(session.user.organizationId, session.user.id, 'logout', requestId(request)); - app.log.info({ user_id: session.user.id, request_id: requestId(request) }, 'administrator logged out'); + app.log.info({ user_id: session.user.id, request_id: requestId(request) }, 'user logged out'); return { ok: true }; }); app.get('/api/tasks', async (request) => { - const session = await getSession(request); + const session = await getReadySession(request); const query = listTasksQuerySchema.parse(request.query || {}); const page = await tasks.listTasksPage(session.user.organizationId, { status: query.status || undefined, search: query.search || undefined, limit: query.limit, offset: query.offset, - includeTotal: query.include_total + includeTotal: query.include_total, + archive: query.archive, + access: contextFor(session, request) }); return { ok: true, @@ -1020,17 +1224,28 @@ export async function buildServer({ }); app.get('/api/tasks/:taskId', async (request) => { - const session = await getSession(request); + const session = await getReadySession(request); const params = request.params as { taskId: string }; - return { ok: true, task: await tasks.getTask(session.user.organizationId, params.taskId) }; + return { ok: true, task: await tasks.getTask(session.user.organizationId, params.taskId, contextFor(session, request)) }; + }); + + app.get('/api/tasks/:taskId/input-history', async (request) => { + const session = await getReadySession(request); + const params = request.params as { taskId: string }; + return { ok: true, ...(await tasks.getTaskInputHistory(contextFor(session, request), params.taskId)) }; }); app.get('/api/tasks/:taskId/artifacts/:artifactId', async (request, reply) => { - const session = await getSession(request); + const session = await getReadySession(request); const params = request.params as { taskId: string; artifactId: string }; const artifactId = z.string().uuid().safeParse(params.artifactId); if (!artifactId.success) throw new TaskError('artifact_not_found', '附件不存在或无权访问。', 404); - const artifact = await tasks.getTaskArtifact(session.user.organizationId, params.taskId, artifactId.data); + const artifact = await tasks.getTaskArtifact( + session.user.organizationId, + params.taskId, + artifactId.data, + contextFor(session, request) + ); if (artifact.storage_backend === 'oss' && artifact.public_url) { reply.header('Cache-Control', 'no-store'); return reply.redirect(artifact.public_url, 302); @@ -1090,13 +1305,49 @@ export async function buildServer({ app.post('/api/tasks/bulk-delete', async (request) => { const session = await requireMutationSession(request); const body = taskBulkDeleteSchema.parse(request.body); - return { ok: true, ...(await tasks.hardDeleteTasks(contextFor(session, request), body.task_ids)) }; + return { + ok: true, + archived: true, + ...(await tasks.archiveTasks(contextFor(session, request), body.task_ids, body.reason)) + }; + }); + + app.post('/api/tasks/bulk-archive', async (request) => { + const session = await requireMutationSession(request); + const body = taskBulkDeleteSchema.parse(request.body); + return { + ok: true, + archived: true, + ...(await tasks.archiveTasks(contextFor(session, request), body.task_ids, body.reason)) + }; }); app.delete('/api/tasks/:taskId', async (request) => { const session = await requireMutationSession(request); const params = request.params as { taskId: string }; - return { ok: true, ...(await tasks.hardDeleteTask(contextFor(session, request), params.taskId)) }; + const body = taskArchiveSchema.parse(request.body || {}); + return { + ok: true, + archived: true, + task: await tasks.archiveTask(contextFor(session, request), params.taskId, body.reason) + }; + }); + + app.post('/api/tasks/:taskId/archive', async (request) => { + const session = await requireMutationSession(request); + const params = request.params as { taskId: string }; + const body = taskArchiveSchema.parse(request.body || {}); + return { + ok: true, + archived: true, + task: await tasks.archiveTask(contextFor(session, request), params.taskId, body.reason) + }; + }); + + app.post('/api/tasks/:taskId/restore', async (request) => { + const session = await requireMutationSession(request); + const params = request.params as { taskId: string }; + return { ok: true, restored: true, task: await tasks.restoreTask(contextFor(session, request), params.taskId) }; }); app.post('/api/connections/heartbeat', async (request) => { @@ -1106,8 +1357,53 @@ export async function buildServer({ return { ok: true, connected: true }; }); + app.get('/api/audit', async (request) => { + const session = await requireAdminSession(request); + const query = auditQuerySchema.parse(request.query || {}); + return { + ok: true, + ...(await tasks.listAuditEvents(contextFor(session, request), { + eventType: query.event_type, + actorUserId: query.actor_user_id, + entityType: query.entity_type, + limit: query.limit, + offset: query.offset + })) + }; + }); + + app.get('/api/operations-dashboard', async (request, reply) => { + setAuthNoStore(reply); + const session = await requireLeadershipSession(request); + const query = operationsDashboardQuerySchema.parse(request.query || {}); + return { + ok: true, + ...(await tasks.listOperationsDashboard(contextFor(session, request), { + from: query.from, + to: query.to, + actorUserId: query.actor_user_id, + businessRouteId: query.business_route_id, + status: query.status, + search: query.search, + limit: query.limit, + offset: query.offset + })) + }; + }); + + app.get('/api/operations-dashboard/tasks/:taskId', async (request, reply) => { + setAuthNoStore(reply); + const session = await requireLeadershipSession(request); + const params = request.params as { taskId: string }; + return { + ok: true, + read_only: true, + ...(await tasks.getOperationsDashboardTask(contextFor(session, request), params.taskId)) + }; + }); + app.get('/api/events', async (request, reply) => { - const session = await getSession(request); + const session = await getReadySession(request); const query = (request.query || {}) as Record; const querySince = Number(query.since || 0); const reconnectSince = Number(request.headers['last-event-id'] || 0); @@ -1125,9 +1421,23 @@ export async function buildServer({ }); const send = (event: TaskEvent) => { if (event.organization_id !== session.user.organizationId) return; - response.write(`id: ${event.id}\nevent: task\ndata: ${JSON.stringify(event)}\n\n`); + if (!canAccessTask(contextFor(session, request), { + createdBy: event.owner_user_id, + source: event.task_source || 'agentbus' + })) return; + const publicEvent = { + id: event.id, + organization_id: event.organization_id, + task_id: event.task_id, + status: event.status, + stage: event.stage, + message: event.message, + payload: event.payload, + created_at: event.created_at + }; + response.write(`id: ${event.id}\nevent: task\ndata: ${JSON.stringify(publicEvent)}\n\n`); }; - for (const event of await tasks.eventsSince(session.user.organizationId, since)) send(event); + for (const event of await tasks.eventsSince(session.user.organizationId, since, contextFor(session, request))) send(event); const heartbeat = setInterval(() => response.write(': heartbeat\n\n'), 20_000); const onTask = (event: TaskEvent) => send(event); tasks.events.on('task', onTask); diff --git a/control-plane/src/task-service.ts b/control-plane/src/task-service.ts index d2a3051..ba88bcb 100644 --- a/control-plane/src/task-service.ts +++ b/control-plane/src/task-service.ts @@ -59,6 +59,8 @@ export interface TaskEvent { message: string; payload: Record; created_at: string; + owner_user_id?: string | null; + task_source?: TaskSource; } export interface PublicTaskEvent { @@ -68,6 +70,53 @@ export interface PublicTaskEvent { message: string; created_at: string; payload: Record; + actor: PublicTaskActor | null; +} + +export interface PublicTaskActor { + id: string; + username: string; +} + +export interface PublicTaskInputHistoryEntry { + id: string; + turn_no: number; + content: string; + source: 'manual' | 'agentbus' | 'reparse' | 'system'; + actor: PublicTaskActor | null; + created_at: string; +} + +export interface PublicTaskInputAttachmentAudit { + id: string; + file_name: string; + source: 'manual' | 'agentbus'; + status: string; + content_type: string; + byte_size: number; + sha256: string; + row_count: number | null; + actor: PublicTaskActor | null; + created_at: string; +} + +export interface PublicAuditEvent { + id: number; + event_type: string; + entity_type: string; + entity_id: string | null; + request_id: string | null; + metadata: Record; + actor: PublicTaskActor | null; + created_at: string; +} + +export interface PublicAuditPage { + events: PublicAuditEvent[]; + total: number; + offset: number; + limit: number; + has_more: boolean; } export interface PublicTaskFailure { @@ -122,6 +171,8 @@ export interface PublicTaskImportantMessage { export type TaskSource = 'manual' | 'agentbus'; export type ConfirmationMode = 'manual' | 'automatic'; +export type TaskRole = 'admin' | 'team_lead' | 'user'; +export type OperationsDashboardStatus = 'all' | 'active' | 'completed' | 'attention' | 'failed' | 'cancelled' | 'archived'; export function shouldAutomaticallyConfirm({ needsInput, @@ -293,6 +344,10 @@ export interface PublicTask { handoff_status: string; confirmation_mode: ConfirmationMode; confirmed_at: string | null; + creator?: PublicTaskActor | null; + archived_at?: string | null; + archived_by?: PublicTaskActor | null; + archive_reason?: string | null; created_at: string; updated_at: string; last_event_id: number | null; @@ -326,6 +381,10 @@ export interface PublicTaskSummary { handoff_status: string; confirmation_mode: ConfirmationMode; confirmed_at: string | null; + creator?: PublicTaskActor | null; + archived_at?: string | null; + archived_by?: PublicTaskActor | null; + archive_reason?: string | null; created_at: string; updated_at: string; last_event_id: number | null; @@ -339,6 +398,110 @@ export interface PublicTaskPage { has_more: boolean; } +export interface PublicOperationsDashboardActor { + id: string; + username: string; + role: TaskRole; + is_active: boolean; +} + +export interface PublicOperationsDashboardSummary { + total: number; + active: number; + completed: number; + attention: number; + failed: number; + cancelled: number; + archived: number; + user_count: number; +} + +export type OperationsDashboardOutcomeKind = 'active' | 'completed' | 'attention' | 'failed' | 'cancelled'; + +export interface PublicOperationsDashboardBreakdown { + key: string; + label: string; + total: number; + active: number; + completed: number; + attention: number; + failed: number; + cancelled: number; +} + +export interface PublicOperationsDashboardUser { + actor: PublicOperationsDashboardActor; + total: number; + active: number; + completed: number; + attention: number; + failed: number; + cancelled: number; + archived: number; + last_activity_at: string | null; +} + +export interface PublicOperationsDashboardTask { + task_id: string; + creator: PublicOperationsDashboardActor; + instruction_preview: string; + result_summary: string; + outcome_kind: OperationsDashboardOutcomeKind; + outcome_label: string; + business_route_id: string | null; + business_route_label: string; + business_day: string; + archived_at: string | null; + created_at: string; + updated_at: string; + input_turn_count: number; +} + +export interface PublicOperationsDashboardInstruction { + turn_no: number; + content: string; + actor: PublicTaskActor | null; + created_at: string; +} + +export interface PublicOperationsDashboardInputAttachment { + file_name: string; + row_count: number | null; + actor: PublicTaskActor | null; + created_at: string; +} + +export interface PublicOperationsDashboardTaskDetail { + task_id: string; + creator: PublicOperationsDashboardActor; + business_route_id: string | null; + business_route_label: string; + outcome_kind: OperationsDashboardOutcomeKind; + outcome_label: string; + result_summary: string; + result_recorded_at: string; + created_at: string; + updated_at: string; + archived_at: string | null; + instructions: PublicOperationsDashboardInstruction[]; + attachments: PublicOperationsDashboardInputAttachment[]; +} + +export interface PublicOperationsDashboardPage { + summary: PublicOperationsDashboardSummary; + users: PublicOperationsDashboardUser[]; + actors: PublicOperationsDashboardActor[]; + days: PublicOperationsDashboardBreakdown[]; + businesses: PublicOperationsDashboardBreakdown[]; + business_options: Array<{ key: string; label: string }>; + tasks: PublicOperationsDashboardTask[]; + range: { from: string; to: string }; + total: number; + offset: number; + limit: number; + has_more: boolean; +} + export interface ParseTaskClaim { task: PublicTask; attemptNo: number; @@ -364,10 +527,51 @@ export interface TaskContext { organizationId: string; userId: string; requestId: string; + role?: TaskRole; source?: TaskSource; channelId?: string; } +export interface TaskAccessScope { + userId: string; + role?: TaskRole; +} + +export function isTaskOwnerRestricted(role: TaskRole | undefined): boolean { + return role === 'team_lead' || role === 'user'; +} + +export function canViewOperationsDashboard(role: TaskRole | undefined): boolean { + return role === 'admin' || role === 'team_lead'; +} + +export function canAccessTask( + access: TaskAccessScope, + task: { createdBy: string | null | undefined; source: TaskSource } +): boolean { + if (!isTaskOwnerRestricted(access.role)) return true; + return Boolean(access.userId) + && task.createdBy === access.userId + && task.source === 'manual'; +} + +export function canExecuteBusinessRoute({ + role, + source, + routeId, + authorizedRouteIds +}: { + role: TaskRole | undefined; + source: TaskSource; + routeId: BusinessRouteId | null; + authorizedRouteIds: readonly BusinessRouteId[]; +}): boolean { + if (source !== 'manual') return true; + if (role === 'admin') return true; + if (!isTaskOwnerRestricted(role) || !routeId) return false; + return authorizedRouteIds.includes(routeId); +} + export interface TaskMessageInput { message: string; attachments?: TaskInputAttachmentInput[]; @@ -451,6 +655,15 @@ export class TaskError extends Error { } } +interface BusinessAuthorizationDecision { + allowed: boolean; + code: 'business_not_authorized' | 'business_type_unresolved' | ''; + message: string; + routeId: BusinessRouteId | null; + directive: string | null; + authorizationRevision: number; +} + function jsonObject(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } @@ -459,6 +672,50 @@ function text(value: unknown): string { return value == null ? '' : String(value); } +function taskRole(value: unknown): TaskRole { + if (value === 'admin' || value === 'team_lead') return value; + return 'user'; +} + +function databaseBoolean(value: unknown): boolean { + return value === true || text(value) === 'true'; +} + +const OPERATIONS_COMPLETED_SQL = "t.status IN ('completed', 'dry_run')"; +const OPERATIONS_ATTENTION_SQL = `t.status IN ( + 'awaiting_attachment', 'awaiting_user_input', 'awaiting_confirmation', + 'reconciliation_pending', 'saved_unverified', 'execution_uncertain', 'uncertain' +)`; +const OPERATIONS_FAILED_SQL = `t.status IN ( + 'failed', 'parse_failed', 'parse_blocked', 'agent_parse_blocked', + 'blocked', 'operation_blocked' +)`; +const OPERATIONS_ACTIVE_SQL = `t.archived_at IS NULL + AND t.status <> 'cancelled' + AND NOT (${OPERATIONS_COMPLETED_SQL}) + AND NOT (${OPERATIONS_ATTENTION_SQL}) + AND NOT (${OPERATIONS_FAILED_SQL})`; + +const OPERATIONS_COMPLETED_STATUSES = new Set(['completed', 'dry_run']); +const OPERATIONS_ATTENTION_STATUSES = new Set([ + 'awaiting_attachment', 'awaiting_user_input', 'awaiting_confirmation', + 'reconciliation_pending', 'saved_unverified', 'execution_uncertain', 'uncertain' +]); +const OPERATIONS_FAILED_STATUSES = new Set([ + 'failed', 'parse_failed', 'parse_blocked', 'agent_parse_blocked', + 'blocked', 'operation_blocked' +]); + +function operationsStatusSql(status: OperationsDashboardStatus): string | null { + if (status === 'active') return `(${OPERATIONS_ACTIVE_SQL})`; + if (status === 'completed') return `(${OPERATIONS_COMPLETED_SQL})`; + if (status === 'attention') return `(${OPERATIONS_ATTENTION_SQL})`; + if (status === 'failed') return `(${OPERATIONS_FAILED_SQL})`; + if (status === 'cancelled') return "t.status = 'cancelled'"; + if (status === 'archived') return 't.archived_at IS NOT NULL'; + return null; +} + interface PreparedExecutionArtifact extends Omit { source: Record; } @@ -1434,6 +1691,12 @@ function executionSummary(value: unknown): Record { }; } +function publicActor(id: unknown, username: unknown): PublicTaskActor | null { + const actorId = text(id); + const actorUsername = text(username); + return actorId && actorUsername ? { id: actorId, username: actorUsername } : null; +} + function publicTaskEvent(row: Record): PublicTaskEvent { return { id: Number(row.id), @@ -1441,7 +1704,8 @@ function publicTaskEvent(row: Record): PublicTaskEvent { stage: text(row.stage), message: text(row.message), created_at: new Date(String(row.created_at)).toISOString(), - payload: redactTransportMetadata(row.payload || {}) as Record + payload: redactTransportMetadata(row.payload || {}) as Record, + actor: publicActor(row.actor_user_id, row.actor_username) }; } @@ -1561,6 +1825,232 @@ function publicImportantMessage( return null; } +function operationsDashboardOutcomeKind(statusValue: unknown): OperationsDashboardOutcomeKind { + const status = text(statusValue).toLowerCase(); + if (OPERATIONS_COMPLETED_STATUSES.has(status)) return 'completed'; + if (OPERATIONS_ATTENTION_STATUSES.has(status)) return 'attention'; + if (OPERATIONS_FAILED_STATUSES.has(status) || isErrorSummaryStatus(status)) return 'failed'; + if (status === 'cancelled') return 'cancelled'; + return 'active'; +} + +function operationsDashboardOutcomeLabel(kind: OperationsDashboardOutcomeKind): string { + if (kind === 'completed') return '已完成'; + if (kind === 'attention') return '待处理'; + if (kind === 'failed') return '异常'; + if (kind === 'cancelled') return '已取消'; + return '处理中'; +} + +function operationsDashboardBusiness(routeId: unknown): { id: string | null; label: string } { + const route = businessRouteById(routeId); + return route + ? { id: route.routeId, label: route.directive } + : { id: null, label: '未识别业务' }; +} + +function operationsDashboardSuccessText(receipt: PublicTaskSuccessReceipt): string { + const customerReply = replyTextFromReceipt(receipt.receipt); + if (customerReply) return customerReply; + const lines = ['业务处理已完成。']; + const groups = uniqueTextValues(receipt.receipt.group_numbers, receipt.receipt.group_number); + const orders = uniqueTextValues(receipt.receipt.order_numbers, receipt.receipt.order_number); + if (groups.length) lines.push(`团号:${groups.join('、')}`); + if (orders.length) lines.push(`订单号:${orders.join('、')}`); + return lines.join('\n'); +} + +function operationsDashboardOutcome(row: Record): { + kind: OperationsDashboardOutcomeKind; + label: string; + text: string; + recordedAt: string; +} { + const kind = operationsDashboardOutcomeKind(row.status); + const status = text(row.status).toLowerCase(); + let resultText = ''; + let recordedAt = isoTimestamp(row.updated_at) || new Date(0).toISOString(); + if (kind === 'completed') { + const receipt = publicSuccessReceipt(row, row.execution_result, row.operation); + if (receipt) { + resultText = operationsDashboardSuccessText(receipt); + recordedAt = receipt.recorded_at; + } else if (status === 'dry_run') { + resultText = '业务方案已完成,未写入 ERP。'; + } + } else if (kind === 'failed') { + const error = publicErrorSummary(row, null); + resultText = error?.message ? `本次指令未完成:${error.message}` : '本次指令未完成。'; + if (error) recordedAt = error.recorded_at; + } else if (kind === 'attention') { + if (status === 'awaiting_user_input') { + resultText = publicAgentReply(row.parse_response) || '等待补充业务信息。'; + } else if (status === 'awaiting_attachment') { + resultText = '等待补充业务附件。'; + } else if (status === 'awaiting_confirmation') { + resultText = '业务方案已生成,等待提交确认。'; + } else { + resultText = '业务结果待核验,请勿重复提交。'; + } + } else if (kind === 'cancelled') { + resultText = '指令已取消,未形成业务结果。'; + } else { + resultText = '正在处理,尚未形成业务结果。'; + } + if (!resultText) { + if (kind === 'completed') resultText = '业务处理已完成,未记录可展示的结果摘要。'; + else if (kind === 'attention') resultText = '等待业务人员补充或确认。'; + else if (kind === 'cancelled') resultText = '任务已取消。'; + else resultText = '正在处理,尚未形成业务结果。'; + } + return { + kind, + label: operationsDashboardOutcomeLabel(kind), + text: resultText.trim().slice(0, 20_000), + recordedAt + }; +} + +interface OperationsDashboardTaskProjection { + task: PublicOperationsDashboardTask; + instruction: string; + result: string; +} + +function operationsDashboardTaskProjection( + config: AppConfig, + row: Record +): OperationsDashboardTaskProjection { + const instruction = decryptText( + config, + row.original_text_ciphertext as string | null | undefined + ).trim(); + const outcome = operationsDashboardOutcome(row); + const business = operationsDashboardBusiness(row.business_route_id); + const instructionPreview = instruction.replace(/\s+/g, ' ').trim(); + const resultPreview = outcome.text.replace(/\s+/g, ' ').trim(); + return { + task: { + task_id: text(row.task_id), + creator: { + id: text(row.actor_id), + username: text(row.actor_username) || '未知账号', + role: taskRole(row.actor_role), + is_active: databaseBoolean(row.actor_is_active) + }, + instruction_preview: instructionPreview.length > 360 + ? `${instructionPreview.slice(0, 360)}…` + : instructionPreview, + result_summary: resultPreview.length > 500 + ? `${resultPreview.slice(0, 500)}…` + : resultPreview, + outcome_kind: outcome.kind, + outcome_label: outcome.label, + business_route_id: business.id, + business_route_label: business.label, + business_day: text(row.business_day), + archived_at: row.archived_at ? new Date(String(row.archived_at)).toISOString() : null, + created_at: new Date(String(row.created_at)).toISOString(), + updated_at: new Date(String(row.updated_at)).toISOString(), + input_turn_count: Number(row.input_turn_count || 0) + }, + instruction, + result: outcome.text + }; +} + +function normalizeOperationsDashboardSearch(value: unknown): string { + return text(value).normalize('NFKC').toLocaleLowerCase('zh-CN').replace(/\s+/g, ' ').trim(); +} + +function operationsDashboardSearchMatches(query: string, values: unknown[]): boolean { + const terms = normalizeOperationsDashboardSearch(query).split(' ').filter(Boolean); + if (!terms.length) return true; + const haystack = normalizeOperationsDashboardSearch(values.map((value) => text(value)).join('\n')); + const compactHaystack = haystack.replace(/\s+/g, ''); + return terms.every((term) => haystack.includes(term) || compactHaystack.includes(term.replace(/\s+/g, ''))); +} + +function operationsDashboardSummaryFromTasks( + tasks: PublicOperationsDashboardTask[] +): PublicOperationsDashboardSummary { + return { + total: tasks.length, + active: tasks.filter((task) => task.outcome_kind === 'active').length, + completed: tasks.filter((task) => task.outcome_kind === 'completed').length, + attention: tasks.filter((task) => task.outcome_kind === 'attention').length, + failed: tasks.filter((task) => task.outcome_kind === 'failed').length, + cancelled: tasks.filter((task) => task.outcome_kind === 'cancelled').length, + archived: tasks.filter((task) => Boolean(task.archived_at)).length, + user_count: new Set(tasks.map((task) => task.creator.id).filter(Boolean)).size + }; +} + +function operationsDashboardBreakdownsFromTasks( + tasks: PublicOperationsDashboardTask[], + keyFor: (task: PublicOperationsDashboardTask) => { key: string; label: string }, + sort: (left: PublicOperationsDashboardBreakdown, right: PublicOperationsDashboardBreakdown) => number +): PublicOperationsDashboardBreakdown[] { + const values = new Map(); + for (const task of tasks) { + const { key, label } = keyFor(task); + const current = values.get(key) || { + key, + label, + total: 0, + active: 0, + completed: 0, + attention: 0, + failed: 0, + cancelled: 0 + }; + current.total += 1; + if (task.outcome_kind === 'active') current.active += 1; + if (task.outcome_kind === 'completed') current.completed += 1; + if (task.outcome_kind === 'attention') current.attention += 1; + if (task.outcome_kind === 'failed') current.failed += 1; + if (task.outcome_kind === 'cancelled') current.cancelled += 1; + values.set(key, current); + } + return [...values.values()].sort(sort); +} + +function operationsDashboardUsersFromTasks( + tasks: PublicOperationsDashboardTask[] +): PublicOperationsDashboardUser[] { + const values = new Map(); + for (const task of tasks) { + const key = task.creator.id || task.creator.username; + const current = values.get(key) || { + actor: task.creator, + total: 0, + active: 0, + completed: 0, + attention: 0, + failed: 0, + cancelled: 0, + archived: 0, + last_activity_at: null + }; + current.total += 1; + if (task.outcome_kind === 'active') current.active += 1; + if (task.outcome_kind === 'completed') current.completed += 1; + if (task.outcome_kind === 'attention') current.attention += 1; + if (task.outcome_kind === 'failed') current.failed += 1; + if (task.outcome_kind === 'cancelled') current.cancelled += 1; + if (task.archived_at) current.archived += 1; + if (!current.last_activity_at || task.updated_at > current.last_activity_at) { + current.last_activity_at = task.updated_at; + } + values.set(key, current); + } + return [...values.values()].sort((left, right) => ( + right.total - left.total + || text(right.last_activity_at).localeCompare(text(left.last_activity_at)) + || left.actor.username.localeCompare(right.actor.username, 'zh-CN') + )); +} + export interface ExecutionClassification { rawStatus: string; status: string; @@ -1882,6 +2372,222 @@ export class TaskService { this.artifactStore = artifactStore || createTaskArtifactStore(config, logger); } + private isOwnerRestrictedUser(context: TaskContext): boolean { + return isTaskOwnerRestricted(context.role); + } + + private requireOperationsDashboardAccess(context: TaskContext): void { + if (!canViewOperationsDashboard(context.role)) { + throw new TaskError('leadership_required', '需要组长或管理员权限。', 403); + } + } + + private async businessAuthorizationDecision( + client: import('pg').PoolClient, + context: TaskContext, + routeId: BusinessRouteId | null + ): 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 ( + SELECT 1 + FROM user_business_route_authorizations route_grant + WHERE route_grant.organization_id = u.organization_id + AND route_grant.user_id = u.id + AND route_grant.route_id = $3 + ) AS route_authorized + FROM users u + WHERE u.organization_id = $1 AND u.id = $2 + FOR SHARE OF u`, + [context.organizationId, context.userId || null, route?.routeId || null] + ); + const row = user.rows[0] as Record | undefined; + const role = row && databaseBoolean(row.is_active) ? taskRole(row.role) : undefined; + const authorizedRouteIds = row && databaseBoolean(row.route_authorized) && route + ? [route.routeId] + : []; + const authorizationRevision = Math.max(0, Number(row?.business_authorization_revision || 0)); + if (canExecuteBusinessRoute({ role, source, routeId: route?.routeId || null, authorizedRouteIds })) { + return { + allowed: true, + code: '', + message: '', + routeId: route?.routeId || null, + directive: route?.directive || null, + authorizationRevision + }; + } + if (!route) { + return { + allowed: false, + code: 'business_type_unresolved', + message: '当前指令无法确认任务类型,已禁止执行。请使用管理员已授权的业务模板后重新提交。', + routeId: null, + directive: null, + authorizationRevision + }; + } + return { + allowed: false, + code: 'business_not_authorized', + message: `当前账号未授权“${route.directive}”业务,已禁止执行。请联系管理员授权后重试。`, + routeId: route.routeId, + directive: route.directive, + authorizationRevision + }; + } + + private businessAuthorizationError(decision: BusinessAuthorizationDecision): TaskError { + return new TaskError(decision.code || 'business_not_authorized', decision.message || '当前账号未授权该业务。', 403, { + route_id: decision.routeId, + directive: decision.directive, + authorization_revision: decision.authorizationRevision, + no_parse: true, + no_plugin_dispatch: true, + no_erp_write: true + }); + } + + private async requireBusinessAuthorization( + context: TaskContext, + routeId: BusinessRouteId | null, + inputHash: string, + phase: 'task_create' | 'message' | 'attachment' + ): Promise { + const decision = await withTransaction(this.config, async (client) => { + const authorization = await this.businessAuthorizationDecision(client, context, routeId); + if (!authorization.allowed) { + await this.audit( + client, + context, + 'task.business_authorization_denied', + authorization.routeId || 'unclassified', + { + route_id: authorization.routeId, + directive: authorization.directive, + authorization_revision: authorization.authorizationRevision, + phase, + input_hash: inputHash, + no_parse: true, + no_plugin_dispatch: true, + no_erp_write: true + }, + 'business_route' + ); + } + return authorization; + }); + if (!decision.allowed) throw this.businessAuthorizationError(decision); + } + + private async assertBusinessAuthorizationInTransaction( + client: import('pg').PoolClient, + context: TaskContext, + routeId: BusinessRouteId | null + ): Promise { + const decision = await this.businessAuthorizationDecision(client, context, routeId); + if (!decision.allowed) throw this.businessAuthorizationError(decision); + return decision; + } + + private taskAuthorizationContext(row: Record, requestId: string): TaskContext { + return { + organizationId: text(row.organization_id), + userId: text(row.created_by), + requestId, + source: normalizeTaskSource(row.source) + }; + } + + private async assertTaskCreatorBusinessAuthorizationInTransaction( + client: import('pg').PoolClient, + row: Record, + requestId: string + ): Promise { + const decision = await this.businessAuthorizationDecision( + client, + this.taskAuthorizationContext(row, requestId), + businessRouteById(row.business_route_id)?.routeId || null + ); + if (!decision.allowed) { + const base = this.businessAuthorizationError(decision); + const message = decision.directive + ? `该任务的创建账号未授权“${decision.directive}”业务,已禁止执行。请先由管理员重新授权。` + : '该任务的创建账号无法获得明确任务类型授权,已禁止执行。'; + throw new TaskError(base.code, message, base.statusCode, base.details); + } + return decision; + } + + private idempotencyActorId(context: TaskContext): string | null { + return normalizeTaskSource(context.source) === 'manual' ? context.userId || null : null; + } + + private async lockIdempotencyKey( + client: import('pg').PoolClient, + organizationId: string, + scope: string, + key: string, + actorUserId: string | null + ): Promise { + await client.query( + `SELECT pg_advisory_xact_lock( + hashtextextended($1::text || ':' || COALESCE($2::text, 'system') || ':' || $3::text || ':' || $4::text, 0) + )`, + [organizationId, actorUserId, scope, key] + ); + } + + private async lockTaskForAccess( + client: import('pg').PoolClient, + context: TaskContext, + taskId: string, + { allowArchived = false }: { allowArchived?: boolean } = {} + ): Promise> { + const result = await client.query( + `SELECT * FROM tasks + WHERE organization_id = $1 + AND task_id = $2 + AND ($3::boolean = false OR (created_by = $4 AND source = 'manual')) + FOR UPDATE`, + [context.organizationId, taskId, this.isOwnerRestrictedUser(context), context.userId || null] + ); + if (!result.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); + const row = result.rows[0] as Record; + if (!allowArchived && row.archived_at) { + throw new TaskError('task_archived', '任务已归档,请先恢复后再操作。', 409); + } + return row; + } + + private async requireBrowserConnection( + client: import('pg').PoolClient, + context: TaskContext, + 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`, + [context.organizationId, context.userId, connectionId] + ); + if (!connection.rowCount) { + throw new TaskError('browser_connection_not_owned', '浏览器连接不存在或不属于当前账号,请重新连接。', 403); + } + } + private log( level: 'info' | 'warn' | 'error', metadata: Record, @@ -1946,6 +2652,10 @@ export class TaskService { handoff_status: text(row.handoff_status), 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), + 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, created_at: new Date(String(row.created_at)).toISOString(), updated_at: new Date(String(row.updated_at)).toISOString(), last_event_id: row.last_event_id == null ? null : Number(row.last_event_id), @@ -1998,6 +2708,10 @@ export class TaskService { handoff_status: text(row.handoff_status), 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), + 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, created_at: new Date(String(row.created_at)).toISOString(), updated_at: new Date(String(row.updated_at)).toISOString(), last_event_id: row.last_event_id == null ? null : Number(row.last_event_id) @@ -2012,9 +2726,11 @@ export class TaskService { const eventMap = new Map(); if (!normalizedTaskIds.length) return eventMap; const result = await getPool(this.config).query( - `SELECT t.task_id, e.id, e.status, e.stage, e.message, e.payload, e.created_at + `SELECT t.task_id, e.id, e.status, e.stage, e.message, e.payload, e.created_at, + e.actor_user_id, actor.username AS actor_username FROM task_events e JOIN tasks t ON t.id = e.task_id + LEFT JOIN users actor ON actor.id = e.actor_user_id WHERE e.organization_id = $1 AND e.task_id = ANY($2::uuid[]) ORDER BY e.id ASC`, @@ -2064,7 +2780,9 @@ export class TaskService { stage: text(eventRow.stage), message: text(eventRow.message), payload: jsonObject(eventRow.payload), - created_at: new Date(String(eventRow.created_at)).toISOString() + created_at: new Date(String(eventRow.created_at)).toISOString(), + owner_user_id: text(row.created_by) || null, + task_source: normalizeTaskSource(row.source) }; } @@ -2468,14 +3186,7 @@ export class TaskService { const normalizedReason = String(reason || '').trim().slice(0, 1_000); if (!normalizedReason) throw new TaskError('reason_required', 'AI 重解析必须填写原因。', 400); const event = await withTransaction(this.config, async (client) => { - const lookup = await client.query( - `SELECT * FROM tasks - WHERE organization_id = $1 AND task_id = $2 - FOR UPDATE`, - [context.organizationId, taskId] - ); - if (!lookup.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); - const row = lookup.rows[0] as Record; + const row = await this.lockTaskForAccess(client, context, taskId); const route = businessRouteById(row.business_route_id); if (route && !isParserModeAllowedForRoute(route, 'ai')) { throw new TaskError('ai_reparse_not_supported', `“${route.directive}”固定使用 Program 解析,不能授权 AI 重解析。`, 409, { @@ -2515,15 +3226,17 @@ export class TaskService { const reparseTurn = Number(session.rows[0].current_turn || 0) + 1; await client.query( `INSERT INTO agent_session_messages - (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, status, idempotency_key) - VALUES ($1, $2, $3, $4, 'user', $5, 'queued', $6)`, + (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, + status, idempotency_key, actor_user_id, input_source) + VALUES ($1, $2, $3, $4, 'user', $5, 'queued', $6, $7, 'reparse')`, [ row.organization_id, session.rows[0].id, row.id, reparseTurn, previousMessage.rows[0].content_ciphertext, - `manual-ai-reparse:${text(row.task_id)}:${reparseTurn}` + `manual-ai-reparse:${text(row.task_id)}:${reparseTurn}`, + context.userId || null ] ); await client.query( @@ -2559,7 +3272,7 @@ export class TaskService { return taskEvent; }); this.notify(event); - return this.getTask(context.organizationId, taskId); + return this.getTask(context.organizationId, taskId, context); } private async recordParseDecision( @@ -2701,7 +3414,7 @@ export class TaskService { } private async passengerRosterAttachmentTarget( - organizationId: string, + context: TaskContext, selection: PassengerRosterAttachmentSelection ): Promise { const taskId = String(selection.taskId || '').trim(); @@ -2714,8 +3427,10 @@ export class TaskService { s.conversation_id FROM tasks t JOIN agent_sessions s ON s.task_id = t.id - WHERE t.organization_id = $1 AND t.task_id = $2`, - [organizationId, taskId] + 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'))`, + [context.organizationId, taskId, this.isOwnerRestrictedUser(context), context.userId || null] ); if (!result.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); } else { @@ -2729,10 +3444,18 @@ export class TaskService { JOIN agent_sessions s ON s.task_id = t.id WHERE t.organization_id = $1 AND t.status = 'awaiting_attachment' + 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')) ORDER BY t.updated_at DESC`, - [organizationId, conversationId, channelId || null] + [ + context.organizationId, + conversationId, + channelId || null, + this.isOwnerRestrictedUser(context), + context.userId || null + ] ); if ((result.rowCount ?? 0) > 1) { throw new TaskError( @@ -2795,13 +3518,15 @@ export class TaskService { attachment: TaskInputAttachmentInput, selection: PassengerRosterAttachmentSelection ): Promise { - const target = await this.passengerRosterAttachmentTarget(context.organizationId, selection); + const target = await this.passengerRosterAttachmentTarget(context, selection); const digest = sha256Bytes(attachment.content); const requestHash = sha256Text(`passenger-workbook\0${digest}`); const idempotencyKey = String(selection.idempotencyKey || '').trim() || null; const scopedIdempotencyKey = target.channelId && idempotencyKey ? `channel:${target.channelId}:${idempotencyKey}` : idempotencyKey; + const idempotencyActorId = this.idempotencyActorId(context); + await this.requireBusinessAuthorization(context, target.routeId, requestHash, 'attachment'); const prior = await getPool(this.config).query( `SELECT status, row_count, error_code, error_details @@ -2847,14 +3572,21 @@ export class TaskService { FROM tasks t 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')) FOR UPDATE OF t, s`, - [context.organizationId, target.rowId] + [context.organizationId, target.rowId, this.isOwnerRestrictedUser(context), context.userId || null] ); if (!locked.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); const taskRow = locked.rows[0] as Record; if (!isPassengerRosterRouteId(taskRow.business_route_id)) { throw new TaskError('attachment_not_supported', '只有补充录入名单业务可以接收 Excel 名单附件。', 409); } + await this.assertBusinessAuthorizationInTransaction( + client, + context, + businessRouteById(taskRow.business_route_id)?.routeId || null + ); if (target.channelId && text(taskRow.channel_id) !== target.channelId) { throw new TaskError('channel_mismatch', '任务不属于当前 AgentBus 渠道。', 409); } @@ -2864,6 +3596,13 @@ export class TaskService { } if (scopedIdempotencyKey) { + await this.lockIdempotencyKey( + client, + context.organizationId, + 'agent_message', + scopedIdempotencyKey, + idempotencyActorId + ); const existingKey = await client.query( `SELECT i.request_hash, t.id, t.task_id FROM idempotency_keys i @@ -2871,8 +3610,16 @@ export class TaskService { WHERE i.organization_id = $1 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')) FOR UPDATE`, - [context.organizationId, scopedIdempotencyKey] + [ + context.organizationId, + scopedIdempotencyKey, + idempotencyActorId, + this.isOwnerRestrictedUser(context), + context.userId || null + ] ); if (existingKey.rowCount) { const keyRow = existingKey.rows[0] as Record; @@ -2906,9 +3653,10 @@ export class TaskService { if (previousAttachment && text(previousAttachment.status) !== 'rejected') { if (scopedIdempotencyKey) { await client.query( - `INSERT INTO idempotency_keys (organization_id, scope, idempotency_key, request_hash, task_id) - VALUES ($1, 'agent_message', $2, $3, $4)`, - [context.organizationId, scopedIdempotencyKey, requestHash, target.rowId] + `INSERT INTO idempotency_keys + (organization_id, scope, idempotency_key, request_hash, task_id, actor_user_id) + VALUES ($1, 'agent_message', $2, $3, $4, $5)`, + [context.organizationId, scopedIdempotencyKey, requestHash, target.rowId, idempotencyActorId] ); } await this.recordAgentBusAcceptedDelivery( @@ -2956,7 +3704,8 @@ export class TaskService { SET source = $2, file_name_ciphertext = $3, content_type = $4, byte_size = $5, status = 'normalized', normalizer_version = $6, normalized_text_ciphertext = $7, row_count = $8, - error_code = NULL, error_details = '{}'::jsonb, updated_at = now() + error_code = NULL, error_details = '{}'::jsonb, + created_by = $9, updated_at = now() WHERE id = $1 AND status = 'rejected'`, [ previousAttachment.id, @@ -2966,7 +3715,8 @@ export class TaskService { attachment.content.byteLength, PASSENGER_ROSTER_WORKBOOK_VERSION, encryptedCanonicalTsv, - normalized.rowCount + normalized.rowCount, + context.userId || null ] ); } else { @@ -2974,9 +3724,9 @@ export class TaskService { `INSERT INTO task_input_attachments (organization_id, task_id, purpose, source, file_name_ciphertext, content_type, byte_size, sha256, status, normalizer_version, - normalized_text_ciphertext, row_count) + normalized_text_ciphertext, row_count, created_by) VALUES ($1, $2, 'passenger_list', $3, $4, $5, $6, $7, - 'normalized', $8, $9, $10)`, + 'normalized', $8, $9, $10, $11)`, [ context.organizationId, target.rowId, @@ -2987,7 +3737,8 @@ export class TaskService { digest, PASSENGER_ROSTER_WORKBOOK_VERSION, encryptedCanonicalTsv, - normalized.rowCount + normalized.rowCount, + context.userId || null ] ); } @@ -3031,7 +3782,8 @@ export class TaskService { SET source = $2, file_name_ciphertext = $3, content_type = $4, byte_size = $5, status = 'rejected', normalizer_version = NULL, normalized_text_ciphertext = NULL, row_count = NULL, - error_code = $6, error_details = $7::jsonb, updated_at = now() + error_code = $6, error_details = $7::jsonb, + created_by = $8, updated_at = now() WHERE id = $1 AND status = 'rejected'`, [ previousAttachment.id, @@ -3040,16 +3792,17 @@ export class TaskService { attachment.contentType, attachment.content.byteLength, safeRejection.code, - safeRejection.details + safeRejection.details, + context.userId || null ] ); } else { await client.query( `INSERT INTO task_input_attachments (organization_id, task_id, purpose, source, file_name_ciphertext, - content_type, byte_size, sha256, status, error_code, error_details) + content_type, byte_size, sha256, status, error_code, error_details, created_by) VALUES ($1, $2, 'passenger_list', $3, $4, $5, $6, $7, - 'rejected', $8, $9::jsonb)`, + 'rejected', $8, $9::jsonb, $10)`, [ context.organizationId, target.rowId, @@ -3059,7 +3812,8 @@ export class TaskService { attachment.content.byteLength, digest, safeRejection.code, - safeRejection.details + safeRejection.details, + context.userId || null ] ); } @@ -3095,9 +3849,10 @@ export class TaskService { if (scopedIdempotencyKey) { await client.query( - `INSERT INTO idempotency_keys (organization_id, scope, idempotency_key, request_hash, task_id) - VALUES ($1, 'agent_message', $2, $3, $4)`, - [context.organizationId, scopedIdempotencyKey, requestHash, target.rowId] + `INSERT INTO idempotency_keys + (organization_id, scope, idempotency_key, request_hash, task_id, actor_user_id) + VALUES ($1, 'agent_message', $2, $3, $4, $5)`, + [context.organizationId, scopedIdempotencyKey, requestHash, target.rowId, idempotencyActorId] ); } await this.recordAgentBusAcceptedDelivery( @@ -3116,7 +3871,7 @@ export class TaskService { }); if (outcome.event) this.notify(outcome.event); return { - task: await this.getTask(context.organizationId, target.taskId), + task: await this.getTask(context.organizationId, target.taskId, context), attached: true, created: selection.created === true, input_attachment: outcome.inputAttachment @@ -3138,14 +3893,32 @@ export class TaskService { const source = normalizeTaskSource(context.source); const attachment = attachments[0]; const requestHash = sha256Text(`${normalized}\0${attachment ? sha256Bytes(attachment.content) : ''}`); + const idempotencyActorId = this.idempotencyActorId(context); + const requestedRouteId = resolveBusinessRoute(normalized).routeId; + await this.requireBusinessAuthorization(context, requestedRouteId, requestHash, 'task_create'); const result = await withTransaction(this.config, async (client) => { if (idempotencyKey) { + await this.lockIdempotencyKey( + client, + context.organizationId, + 'create_task', + idempotencyKey, + idempotencyActorId + ); const existing = await client.query( - `SELECT t.* FROM idempotency_keys i + `SELECT t.*, i.request_hash FROM idempotency_keys i 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')) FOR UPDATE`, - [context.organizationId, idempotencyKey] + [ + context.organizationId, + idempotencyKey, + idempotencyActorId, + this.isOwnerRestrictedUser(context), + context.userId || null + ] ); if (existing.rowCount) { if (existing.rows[0].request_hash !== requestHash) { @@ -3156,6 +3929,11 @@ export class TaskService { } const taskId = `TASK-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${randomToken(5)}`; const parserSnapshot = await resolveParserModeSnapshot(client, context.organizationId, normalized); + const authorization = await this.assertBusinessAuthorizationInTransaction( + client, + context, + parserSnapshot.routeId + ); if (attachment && !isPassengerRosterRouteId(parserSnapshot.routeId)) { throw new TaskError('attachment_not_supported', '只有补充录入名单业务可以接收 Excel 名单附件。', 409); } @@ -3186,21 +3964,25 @@ export class TaskService { ); await client.query( `INSERT INTO agent_session_messages - (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, status, idempotency_key) - VALUES ($1, $2, $3, 1, 'user', $4, 'queued', $5)`, + (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, + status, idempotency_key, actor_user_id, input_source) + VALUES ($1, $2, $3, 1, 'user', $4, 'queued', $5, $6, $7)`, [ row.organization_id, session.rows[0].id, row.id, encryptText(this.config, normalized), - `task-initial:${taskId}` + `task-initial:${taskId}`, + context.userId || null, + source ] ); if (idempotencyKey) { await client.query( - `INSERT INTO idempotency_keys (organization_id, scope, idempotency_key, request_hash, task_id) - VALUES ($1, 'create_task', $2, $3, $4)`, - [context.organizationId, idempotencyKey, requestHash, row.id] + `INSERT INTO idempotency_keys + (organization_id, scope, idempotency_key, request_hash, task_id, actor_user_id) + VALUES ($1, 'create_task', $2, $3, $4, $5)`, + [context.organizationId, idempotencyKey, requestHash, row.id, idempotencyActorId] ); } const event = await this.emitEvent(client, row, { @@ -3210,6 +3992,8 @@ export class TaskService { }, context.userId); await this.audit(client, context, 'task.created', taskId, { request_hash: requestHash, + business_route_id: parserSnapshot.routeId, + business_authorization_revision: authorization.authorizationRevision, awaiting_attachment: intake.status === 'awaiting_attachment', attachment_present: Boolean(attachment) }); @@ -3225,7 +4009,7 @@ export class TaskService { }); return attachmentResult.task; } - return this.getTask(context.organizationId, taskId); + return this.getTask(context.organizationId, taskId, context); } async ingestMessage(context: TaskContext, input: TaskMessageInput): Promise { @@ -3275,6 +4059,51 @@ export class TaskService { : idempotencyKey; const source = normalizeTaskSource(context.source); const requestHash = sha256Text(normalizedMessage); + const idempotencyActorId = this.idempotencyActorId(context); + let preflightRouteId: BusinessRouteId | null | undefined; + if (newDirective) { + preflightRouteId = resolveBusinessRoute(normalizedMessage).routeId; + } else if (requestedTaskId) { + const target = await getPool(this.config).query( + `SELECT business_route_id + 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'))`, + [context.organizationId, requestedTaskId, this.isOwnerRestrictedUser(context), context.userId || null] + ); + if (target.rowCount) preflightRouteId = businessRouteById(target.rows[0].business_route_id)?.routeId || null; + } else if (conversationId) { + const target = await getPool(this.config).query( + `SELECT t.business_route_id + FROM tasks t + JOIN agent_sessions s ON s.task_id = t.id + WHERE t.organization_id = $1 + AND t.status = 'awaiting_user_input' + 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')) + ORDER BY t.updated_at DESC`, + [ + context.organizationId, + conversationId, + channelId || null, + this.isOwnerRestrictedUser(context), + context.userId || null + ] + ); + if (target.rowCount === 1) { + preflightRouteId = businessRouteById(target.rows[0].business_route_id)?.routeId || null; + } else if (!target.rowCount) { + preflightRouteId = resolveBusinessRoute(normalizedMessage).routeId; + } + } else { + preflightRouteId = resolveBusinessRoute(normalizedMessage).routeId; + } + if (preflightRouteId !== undefined) { + await this.requireBusinessAuthorization(context, preflightRouteId, requestHash, 'message'); + } const outcome = await withTransaction(this.config, async (client) => { if (channelId) { const channel = await client.query( @@ -3286,6 +4115,13 @@ export class TaskService { if (!channel.rowCount) throw new TaskError('channel_not_found', '用户渠道不存在。', 404); } if (idempotencyKey) { + await this.lockIdempotencyKey( + client, + context.organizationId, + 'agent_message', + scopedIdempotencyKey || idempotencyKey, + idempotencyActorId + ); const existing = await client.query( `SELECT t.task_id, i.request_hash FROM idempotency_keys i @@ -3293,8 +4129,9 @@ export class TaskService { WHERE i.organization_id = $1 AND i.scope = 'agent_message' AND i.idempotency_key = $2 + AND i.actor_user_id IS NOT DISTINCT FROM $3::uuid FOR UPDATE`, - [context.organizationId, scopedIdempotencyKey] + [context.organizationId, scopedIdempotencyKey, idempotencyActorId] ); if (existing.rowCount) { if (text(existing.rows[0].request_hash) !== requestHash) { @@ -3302,10 +4139,16 @@ export class TaskService { } const existingTask = await client.query( `SELECT id, task_id, channel_id - FROM tasks + FROM tasks WHERE organization_id = $1 AND task_id = $2 + AND ($3::boolean = false OR (created_by = $4 AND source = 'manual')) FOR UPDATE`, - [context.organizationId, text(existing.rows[0].task_id)] + [ + context.organizationId, + text(existing.rows[0].task_id), + this.isOwnerRestrictedUser(context), + context.userId || null + ] ); if (!existingTask.rowCount) throw new TaskError('task_not_found', '幂等键关联的任务不存在。', 404); if (channelId && text(existingTask.rows[0].channel_id) !== channelId) { @@ -3340,8 +4183,10 @@ export class TaskService { FROM tasks t 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')) FOR UPDATE OF t, s`, - [context.organizationId, requestedTaskId] + [context.organizationId, requestedTaskId, this.isOwnerRestrictedUser(context), context.userId || null] ); if (!(result.rowCount ?? 0)) throw new TaskError('task_not_found', '任务不存在。', 404); selected = result.rows[0] as Record; @@ -3371,11 +4216,19 @@ export class TaskService { JOIN agent_sessions s ON s.task_id = t.id WHERE t.organization_id = $1 AND t.status = 'awaiting_user_input' + 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')) ORDER BY t.updated_at DESC FOR UPDATE OF t, s`, - [context.organizationId, conversationId, channelId || null] + [ + context.organizationId, + conversationId, + channelId || null, + this.isOwnerRestrictedUser(context), + context.userId || null + ] ); if ((result.rowCount ?? 0) > 1) { throw new TaskError( @@ -3391,6 +4244,11 @@ export class TaskService { if (!selected) { const taskId = `TASK-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}-${randomToken(5)}`; const parserSnapshot = await resolveParserModeSnapshot(client, context.organizationId, normalizedMessage); + const authorization = await this.assertBusinessAuthorizationInTransaction( + client, + context, + parserSnapshot.routeId + ); const intake = initialTaskIntakeState(parserSnapshot.routeId); const inserted = await client.query( `INSERT INTO tasks @@ -3417,15 +4275,31 @@ export class TaskService { ); await client.query( `INSERT INTO agent_session_messages - (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, status, idempotency_key) - VALUES ($1, $2, $3, 1, 'user', $4, 'queued', $5)`, - [context.organizationId, session.rows[0].id, taskRow.id, encryptText(this.config, normalizedMessage), `task-initial:${taskId}`] + (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, + status, idempotency_key, actor_user_id, input_source) + VALUES ($1, $2, $3, 1, 'user', $4, 'queued', $5, $6, $7)`, + [ + context.organizationId, + session.rows[0].id, + taskRow.id, + encryptText(this.config, normalizedMessage), + `task-initial:${taskId}`, + context.userId || null, + source + ] ); if (idempotencyKey) { await client.query( - `INSERT INTO idempotency_keys (organization_id, scope, idempotency_key, request_hash, task_id) - VALUES ($1, 'agent_message', $2, $3, $4)`, - [context.organizationId, scopedIdempotencyKey || idempotencyKey, requestHash, taskRow.id] + `INSERT INTO idempotency_keys + (organization_id, scope, idempotency_key, request_hash, task_id, actor_user_id) + VALUES ($1, 'agent_message', $2, $3, $4, $5)`, + [ + context.organizationId, + scopedIdempotencyKey || idempotencyKey, + requestHash, + taskRow.id, + idempotencyActorId + ] ); } await this.recordAgentBusAcceptedDelivery( @@ -3449,6 +4323,8 @@ export class TaskService { }, context.userId); await this.audit(client, context, 'task.created_from_message', taskId, { request_hash: requestHash, + business_route_id: parserSnapshot.routeId, + business_authorization_revision: authorization.authorizationRevision, conversation_id_present: Boolean(conversationId), routing: newDirective ? 'new_directive_new_session' : 'new_message_new_session', directive: newDirective, @@ -3457,14 +4333,29 @@ export class TaskService { return { taskId, taskRowId: text(taskRow.id), event, attached: false, created: true }; } + const authorization = await this.assertBusinessAuthorizationInTransaction( + client, + context, + businessRouteById(selected.business_route_id)?.routeId || null + ); const sessionId = selected.agent_session_uuid; const turnNo = Number(selected.agent_session_turn || 0) + 1; const messageKey = idempotencyKey || `task-message:${selected.task_id}:${turnNo}`; await client.query( `INSERT INTO agent_session_messages - (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, status, idempotency_key) - VALUES ($1, $2, $3, $4, 'user', $5, 'queued', $6)`, - [context.organizationId, sessionId, selected.id, turnNo, encryptText(this.config, normalizedMessage), messageKey] + (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, + status, idempotency_key, actor_user_id, input_source) + VALUES ($1, $2, $3, $4, 'user', $5, 'queued', $6, $7, $8)`, + [ + context.organizationId, + sessionId, + selected.id, + turnNo, + encryptText(this.config, normalizedMessage), + messageKey, + context.userId || null, + source + ] ); await client.query( `UPDATE agent_sessions @@ -3486,9 +4377,16 @@ export class TaskService { const updatedRow = updated.rows[0] as Record; if (idempotencyKey) { await client.query( - `INSERT INTO idempotency_keys (organization_id, scope, idempotency_key, request_hash, task_id) - VALUES ($1, 'agent_message', $2, $3, $4)`, - [context.organizationId, scopedIdempotencyKey || idempotencyKey, requestHash, selected.id] + `INSERT INTO idempotency_keys + (organization_id, scope, idempotency_key, request_hash, task_id, actor_user_id) + VALUES ($1, 'agent_message', $2, $3, $4, $5)`, + [ + context.organizationId, + scopedIdempotencyKey || idempotencyKey, + requestHash, + selected.id, + idempotencyActorId + ] ); } await this.recordAgentBusAcceptedDelivery( @@ -3506,21 +4404,25 @@ export class TaskService { }, context.userId); await this.audit(client, context, 'task.message_received', text(selected.task_id), { turn_no: turnNo, - request_hash: requestHash + request_hash: requestHash, + business_route_id: businessRouteById(selected.business_route_id)?.routeId || null, + business_authorization_revision: authorization.authorizationRevision }); return { taskId: text(selected.task_id), taskRowId: text(selected.id), event, attached: true, created: false }; }); if (outcome.event) this.notify(outcome.event); return { - task: await this.getTask(context.organizationId, outcome.taskId), + task: await this.getTask(context.organizationId, outcome.taskId, context), attached: outcome.attached, created: outcome.created }; } - async getTask(organizationId: string, taskId: string): Promise { + async getTask(organizationId: string, taskId: string, access?: TaskAccessScope): Promise { 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, + 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, s.recovery_count AS agent_session_recovery_count, @@ -3539,6 +4441,8 @@ export class TaskService { FROM tasks t 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 archiver ON archiver.id = t.archived_by LEFT JOIN LATERAL ( SELECT id, review_status, diff_summary, program_result_ciphertext, ai_result_ciphertext FROM parse_decisions @@ -3546,8 +4450,9 @@ export class TaskService { ORDER BY attempt_no DESC LIMIT 1 ) pd ON true - WHERE t.organization_id = $1 AND t.task_id = $2`, - [organizationId, taskId] + WHERE t.organization_id = $1 AND t.task_id = $2 + AND ($3::boolean = false OR (t.created_by = $4 AND t.source = 'manual'))`, + [organizationId, taskId, isTaskOwnerRestricted(access?.role), access?.userId || null] ); if (!result.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); const row = result.rows[0] as Record; @@ -3568,6 +4473,525 @@ export class TaskService { return this.publicTask(row, eventMap.get(text(row.task_id)) || []); } + async getTaskInputHistory(context: TaskContext, taskId: string): Promise<{ + messages: PublicTaskInputHistoryEntry[]; + attachments: PublicTaskInputAttachmentAudit[]; + }> { + 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'))`, + [context.organizationId, taskId, this.isOwnerRestrictedUser(context), context.userId || null] + ); + if (!task.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); + const taskRowId = text(task.rows[0].id); + const [messageResult, attachmentResult] = await Promise.all([ + getPool(this.config).query( + `SELECT m.id, m.turn_no, m.content_ciphertext, m.input_source, m.created_at, + m.actor_user_id, actor.username AS actor_username, t.source AS task_source + FROM agent_session_messages m + JOIN tasks t ON t.id = m.task_id + LEFT JOIN users actor ON actor.id = m.actor_user_id + WHERE m.organization_id = $1 AND m.task_id = $2 AND m.role = 'user' + ORDER BY m.turn_no ASC, m.created_at ASC`, + [context.organizationId, taskRowId] + ), + getPool(this.config).query( + `SELECT a.id, a.file_name_ciphertext, a.source, a.status, a.content_type, + a.byte_size, a.sha256, a.row_count, a.created_at, + a.created_by, actor.username AS actor_username + FROM task_input_attachments a + LEFT JOIN users actor ON actor.id = a.created_by + WHERE a.organization_id = $1 AND a.task_id = $2 + ORDER BY a.created_at ASC, a.id ASC`, + [context.organizationId, taskRowId] + ) + ]); + const messages = (messageResult.rows as Record[]).map((row) => { + const source = ['manual', 'agentbus', 'reparse', 'system'].includes(text(row.input_source)) + ? text(row.input_source) as PublicTaskInputHistoryEntry['source'] + : text(row.task_source) === 'agentbus' ? 'agentbus' : 'system'; + return { + id: text(row.id), + turn_no: Number(row.turn_no || 0), + content: decryptText(this.config, row.content_ciphertext as string | null | undefined), + source, + actor: publicActor(row.actor_user_id, row.actor_username), + created_at: new Date(String(row.created_at)).toISOString() + } satisfies PublicTaskInputHistoryEntry; + }); + const attachments = (attachmentResult.rows as Record[]).map((row) => ({ + id: text(row.id), + file_name: decryptText(this.config, row.file_name_ciphertext as string | null | undefined), + source: text(row.source) === 'agentbus' ? 'agentbus' as const : 'manual' as const, + status: text(row.status), + content_type: text(row.content_type), + byte_size: Number(row.byte_size || 0), + sha256: text(row.sha256), + row_count: row.row_count == null ? null : Number(row.row_count), + actor: publicActor(row.created_by, row.actor_username), + created_at: new Date(String(row.created_at)).toISOString() + } satisfies PublicTaskInputAttachmentAudit)); + return { messages, attachments }; + } + + async listOperationsDashboard( + context: TaskContext, + options: { + from?: string; + to?: string; + actorUserId?: string; + businessRouteId?: string; + status?: OperationsDashboardStatus; + search?: string; + limit?: number; + offset?: number; + } = {} + ): Promise { + this.requireOperationsDashboardAccess(context); + const now = new Date(); + const to = options.to ? new Date(options.to) : now; + const from = options.from + ? new Date(options.from) + : new Date(to.getTime() - 30 * 24 * 60 * 60 * 1_000); + const rangeMs = to.getTime() - from.getTime(); + if (!Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || rangeMs <= 0) { + throw new TaskError('operations_dashboard_range_invalid', '看板时间范围无效。', 400); + } + if (rangeMs > 366 * 24 * 60 * 60 * 1_000) { + throw new TaskError('operations_dashboard_range_too_large', '单次最多查询 366 天的数据。', 400); + } + + const status = options.status || 'all'; + const params: unknown[] = [context.organizationId, from.toISOString(), to.toISOString()]; + let where = `t.organization_id = $1 + AND t.source = 'manual' + AND t.created_at >= $2::timestamptz + AND t.created_at < $3::timestamptz`; + const actorUserId = text(options.actorUserId).trim(); + if (actorUserId) { + params.push(actorUserId); + where += ` AND t.created_by = $${params.length}`; + } + const businessRouteId = text(options.businessRouteId).trim(); + if (businessRouteId === 'unclassified') { + where += ' AND t.business_route_id IS NULL'; + } else if (businessRouteId) { + if (!businessRouteById(businessRouteId)) { + throw new TaskError('operations_dashboard_business_invalid', '看板业务类型无效。', 400); + } + params.push(businessRouteId); + where += ` AND t.business_route_id = $${params.length}`; + } + const statusCondition = operationsStatusSql(status); + if (statusCondition) where += ` AND ${statusCondition}`; + const search = text(options.search).trim().toLowerCase(); + + const boundedLimit = Math.max(1, Math.min(100, Math.trunc(options.limit || 50))); + const boundedOffset = Math.max(0, Math.min(1_000_000, Math.trunc(options.offset || 0))); + const pool = getPool(this.config); + const businessOptions = [ + ...BUSINESS_ROUTES.map((route) => ({ key: route.routeId, label: route.directive })), + { key: 'unclassified', label: '未识别业务' } + ]; + const actorQuery = () => pool.query( + `SELECT id, username, role, is_active + FROM users + WHERE organization_id = $1 + ORDER BY is_active DESC, username ASC`, + [context.organizationId] + ); + const taskSelect = `t.id, t.task_id, t.original_text_ciphertext, + t.status, t.message, t.business_route_id, t.archived_at, + t.created_at, t.updated_at, t.success_receipt, t.success_receipt_at, + t.error_summary, t.error_summary_at, t.execution_result, + t.operation, t.parse_response, + creator.id AS actor_id, creator.username AS actor_username, + creator.role AS actor_role, creator.is_active AS actor_is_active, + to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS business_day, + (SELECT COUNT(*)::int + FROM agent_session_messages input_message + WHERE input_message.task_id = t.id + AND input_message.role = 'user' + AND input_message.input_source IS DISTINCT FROM 'reparse') AS input_turn_count`; + + if (search) { + const [candidateResult, actorResult] = await Promise.all([ + pool.query( + `SELECT ${taskSelect} + FROM tasks t + LEFT JOIN users creator ON creator.id = t.created_by + WHERE ${where} + ORDER BY t.created_at DESC, t.id DESC + LIMIT 2001`, + params + ), + actorQuery() + ]); + if (candidateResult.rows.length > 2_000) { + throw new TaskError( + 'operations_dashboard_search_scope_too_large', + '关键词查询范围超过 2000 条,请先缩短日期或选择人员、业务类型后再查询。', + 400 + ); + } + const candidateRows = candidateResult.rows as Record[]; + const taskRowIds = candidateRows.map((row) => text(row.id)).filter(Boolean); + const messageResult = taskRowIds.length + ? await pool.query( + `SELECT m.task_id, m.content_ciphertext, actor.username AS actor_username + FROM agent_session_messages m + LEFT JOIN users actor ON actor.id = m.actor_user_id + WHERE m.organization_id = $1 + AND m.task_id = ANY($2::uuid[]) + AND m.role = 'user' + AND m.input_source IS DISTINCT FROM 'reparse'`, + [context.organizationId, taskRowIds] + ) + : { rows: [] as Record[] }; + const searchValuesByTask = new Map(); + for (const row of messageResult.rows as Record[]) { + const key = text(row.task_id); + const values = searchValuesByTask.get(key) || []; + values.push( + text(row.actor_username), + decryptText(this.config, row.content_ciphertext as string | null | undefined) + ); + searchValuesByTask.set(key, values); + } + const matched = candidateRows.flatMap((row) => { + const projection = operationsDashboardTaskProjection(this.config, row); + const matches = operationsDashboardSearchMatches(search, [ + projection.task.task_id, + projection.task.creator.username, + projection.task.business_route_label, + projection.instruction, + projection.result, + ...(searchValuesByTask.get(text(row.id)) || []) + ]); + return matches ? [projection.task] : []; + }); + const actors = (actorResult.rows as Record[]).map((row) => ({ + id: text(row.id), + username: text(row.username), + role: taskRole(row.role), + is_active: databaseBoolean(row.is_active) + } satisfies PublicOperationsDashboardActor)); + const summary = operationsDashboardSummaryFromTasks(matched); + const days = operationsDashboardBreakdownsFromTasks( + matched, + (task) => ({ key: task.business_day, label: task.business_day }), + (left, right) => right.key.localeCompare(left.key) + ); + const businesses = operationsDashboardBreakdownsFromTasks( + matched, + (task) => ({ + key: task.business_route_id || 'unclassified', + label: task.business_route_label + }), + (left, right) => right.total - left.total || left.label.localeCompare(right.label, 'zh-CN') + ); + const pageTasks = matched.slice(boundedOffset, boundedOffset + boundedLimit); + return { + summary, + users: operationsDashboardUsersFromTasks(matched), + actors, + days, + businesses, + business_options: businessOptions, + tasks: pageTasks, + range: { from: from.toISOString(), to: to.toISOString() }, + total: matched.length, + offset: boundedOffset, + limit: boundedLimit, + has_more: boundedOffset + pageTasks.length < matched.length + }; + } + + const listParams = [...params, boundedLimit, boundedOffset]; + const limitParam = params.length + 1; + const offsetParam = params.length + 2; + const [summaryResult, userResult, actorResult, dayResult, businessResult, listResult] = await Promise.all([ + pool.query( + `SELECT COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active, + COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed, + COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention, + COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed, + COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled, + COUNT(*) FILTER (WHERE t.archived_at IS NOT NULL)::int AS archived, + COUNT(DISTINCT t.created_by)::int AS user_count + FROM tasks t + LEFT JOIN users creator ON creator.id = t.created_by + WHERE ${where}`, + params + ), + pool.query( + `SELECT creator.id AS actor_id, creator.username AS actor_username, + creator.role AS actor_role, creator.is_active AS actor_is_active, + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active, + COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed, + COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention, + COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed, + COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled, + COUNT(*) FILTER (WHERE t.archived_at IS NOT NULL)::int AS archived, + MAX(t.updated_at) AS last_activity_at + FROM tasks t + JOIN users creator ON creator.id = t.created_by + WHERE ${where} + GROUP BY creator.id, creator.username, creator.role, creator.is_active + ORDER BY total DESC, last_activity_at DESC, creator.username ASC`, + params + ), + actorQuery(), + pool.query( + `SELECT to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS dimension_key, + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active, + COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed, + COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention, + COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed, + COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled + FROM tasks t + LEFT JOIN users creator ON creator.id = t.created_by + WHERE ${where} + GROUP BY dimension_key + ORDER BY dimension_key DESC`, + params + ), + pool.query( + `SELECT coalesce(t.business_route_id, 'unclassified') AS dimension_key, + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE ${OPERATIONS_ACTIVE_SQL})::int AS active, + COUNT(*) FILTER (WHERE ${OPERATIONS_COMPLETED_SQL})::int AS completed, + COUNT(*) FILTER (WHERE ${OPERATIONS_ATTENTION_SQL})::int AS attention, + COUNT(*) FILTER (WHERE ${OPERATIONS_FAILED_SQL})::int AS failed, + COUNT(*) FILTER (WHERE t.status = 'cancelled')::int AS cancelled + FROM tasks t + LEFT JOIN users creator ON creator.id = t.created_by + WHERE ${where} + GROUP BY dimension_key + ORDER BY total DESC, dimension_key ASC`, + params + ), + pool.query( + `SELECT ${taskSelect} + FROM tasks t + LEFT JOIN users creator ON creator.id = t.created_by + WHERE ${where} + ORDER BY t.created_at DESC, t.id DESC + LIMIT $${limitParam} OFFSET $${offsetParam}`, + listParams + ) + ]); + + const summaryRow = summaryResult.rows[0] as Record | undefined; + const summary: PublicOperationsDashboardSummary = { + total: Number(summaryRow?.total || 0), + active: Number(summaryRow?.active || 0), + completed: Number(summaryRow?.completed || 0), + attention: Number(summaryRow?.attention || 0), + failed: Number(summaryRow?.failed || 0), + cancelled: Number(summaryRow?.cancelled || 0), + archived: Number(summaryRow?.archived || 0), + user_count: Number(summaryRow?.user_count || 0) + }; + const users = (userResult.rows as Record[]).map((row) => ({ + actor: { + id: text(row.actor_id), + username: text(row.actor_username), + role: taskRole(row.actor_role), + is_active: databaseBoolean(row.actor_is_active) + }, + total: Number(row.total || 0), + active: Number(row.active || 0), + completed: Number(row.completed || 0), + attention: Number(row.attention || 0), + failed: Number(row.failed || 0), + cancelled: Number(row.cancelled || 0), + archived: Number(row.archived || 0), + last_activity_at: row.last_activity_at + ? new Date(String(row.last_activity_at)).toISOString() + : null + } satisfies PublicOperationsDashboardUser)); + const actors = (actorResult.rows as Record[]).map((row) => ({ + id: text(row.id), + username: text(row.username), + role: taskRole(row.role), + is_active: databaseBoolean(row.is_active) + } satisfies PublicOperationsDashboardActor)); + const breakdown = (row: Record, label: string): PublicOperationsDashboardBreakdown => ({ + key: text(row.dimension_key), + label, + total: Number(row.total || 0), + active: Number(row.active || 0), + completed: Number(row.completed || 0), + attention: Number(row.attention || 0), + failed: Number(row.failed || 0), + cancelled: Number(row.cancelled || 0) + }); + const days = (dayResult.rows as Record[]).map((row) => ( + breakdown(row, text(row.dimension_key)) + )); + const businesses = (businessResult.rows as Record[]).map((row) => { + const key = text(row.dimension_key); + return breakdown(row, key === 'unclassified' ? '未识别业务' : operationsDashboardBusiness(key).label); + }); + const tasks = (listResult.rows as Record[]).map((row) => ( + operationsDashboardTaskProjection(this.config, row).task + )); + return { + summary, + users, + actors, + days, + businesses, + business_options: businessOptions, + tasks, + range: { from: from.toISOString(), to: to.toISOString() }, + total: summary.total, + offset: boundedOffset, + limit: boundedLimit, + has_more: boundedOffset + tasks.length < summary.total + }; + } + + async getOperationsDashboardTask( + context: TaskContext, + taskId: string + ): Promise { + this.requireOperationsDashboardAccess(context); + const target = await getPool(this.config).query( + `SELECT t.id, t.task_id, t.original_text_ciphertext, + t.status, t.message, t.business_route_id, t.archived_at, + t.created_at, t.updated_at, t.success_receipt, t.success_receipt_at, + t.error_summary, t.error_summary_at, t.execution_result, + t.operation, t.parse_response, + creator.id AS actor_id, creator.username AS actor_username, + creator.role AS actor_role, creator.is_active AS actor_is_active, + to_char(timezone('Asia/Shanghai', t.created_at), 'YYYY-MM-DD') AS business_day, + (SELECT COUNT(*)::int + FROM agent_session_messages input_message + WHERE input_message.task_id = t.id + AND input_message.role = 'user' + AND input_message.input_source IS DISTINCT FROM 'reparse') AS input_turn_count + FROM tasks t + LEFT JOIN users creator ON creator.id = t.created_by + WHERE t.organization_id = $1 AND t.task_id = $2 AND t.source = 'manual'`, + [context.organizationId, taskId] + ); + if (!target.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); + const row = target.rows[0] as Record; + const projection = operationsDashboardTaskProjection(this.config, row); + const outcome = operationsDashboardOutcome(row); + const privilegedReadContext: TaskContext = { ...context, role: 'admin', source: 'manual' }; + const history = await this.getTaskInputHistory(privilegedReadContext, taskId); + const instructions = history.messages + .filter((message) => message.source !== 'reparse') + .map((message) => ({ + turn_no: message.turn_no, + content: message.content, + actor: message.actor, + created_at: message.created_at + } satisfies PublicOperationsDashboardInstruction)); + if (!instructions.length) { + instructions.push({ + turn_no: 1, + content: projection.instruction, + actor: projection.task.creator.id + ? { id: projection.task.creator.id, username: projection.task.creator.username } + : null, + created_at: projection.task.created_at + }); + } + return { + task_id: projection.task.task_id, + creator: projection.task.creator, + business_route_id: projection.task.business_route_id, + business_route_label: projection.task.business_route_label, + outcome_kind: projection.task.outcome_kind, + outcome_label: projection.task.outcome_label, + result_summary: outcome.text, + result_recorded_at: outcome.recordedAt, + created_at: projection.task.created_at, + updated_at: projection.task.updated_at, + archived_at: projection.task.archived_at, + instructions, + attachments: history.attachments.map((attachment) => ({ + file_name: attachment.file_name, + row_count: attachment.row_count, + actor: attachment.actor, + created_at: attachment.created_at + })) + }; + } + + async listAuditEvents( + context: TaskContext, + options: { + eventType?: string; + actorUserId?: string; + entityType?: string; + limit?: number; + offset?: number; + } = {} + ): Promise { + if (context.role !== 'admin') throw new TaskError('admin_required', '需要管理员权限。', 403); + const params: unknown[] = [context.organizationId]; + let where = 'a.organization_id = $1'; + const eventType = text(options.eventType).trim(); + const actorUserId = text(options.actorUserId).trim(); + const entityType = text(options.entityType).trim(); + if (eventType) { + params.push(eventType); + where += ` AND a.event_type = $${params.length}`; + } + if (actorUserId) { + params.push(actorUserId); + where += ` AND a.actor_user_id = $${params.length}`; + } + if (entityType) { + params.push(entityType); + where += ` AND a.entity_type = $${params.length}`; + } + const boundedLimit = Math.max(1, Math.min(200, Math.trunc(options.limit || 100))); + const boundedOffset = Math.max(0, Math.min(1_000_000, Math.trunc(options.offset || 0))); + const countResult = await getPool(this.config).query( + `SELECT COUNT(*)::int AS total_count FROM audit_events a WHERE ${where}`, + params + ); + const listParams = [...params, boundedLimit, boundedOffset]; + const result = await getPool(this.config).query( + `SELECT a.id, a.event_type, a.entity_type, a.entity_id, a.request_id, + a.metadata, a.created_at, a.actor_user_id, actor.username AS actor_username + FROM audit_events a + LEFT JOIN users actor ON actor.id = a.actor_user_id + WHERE ${where} + ORDER BY a.created_at DESC, a.id DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, + listParams + ); + const events = (result.rows as Record[]).map((row) => ({ + id: Number(row.id), + event_type: text(row.event_type), + entity_type: text(row.entity_type), + entity_id: text(row.entity_id) || null, + request_id: text(row.request_id) || null, + metadata: redactTransportMetadata(row.metadata || {}) as Record, + actor: publicActor(row.actor_user_id, row.actor_username), + created_at: new Date(String(row.created_at)).toISOString() + } satisfies PublicAuditEvent)); + const total = Number(countResult.rows[0]?.total_count || 0); + return { + events, + total, + offset: boundedOffset, + limit: boundedLimit, + has_more: boundedOffset + events.length < total + }; + } + async listAgentBusFinalizationCandidates( channelId: string, limit = 50 @@ -3748,8 +5172,16 @@ export class TaskService { async getTaskArtifact( organizationId: string, taskId: string, - artifactId: string + artifactId: string, + access?: TaskAccessScope ): Promise { + 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'))`, + [organizationId, taskId, isTaskOwnerRestricted(access?.role), access?.userId || null] + ); + if (!authorized.rowCount) throw new TaskError('artifact_not_found', '附件不存在或无权访问。', 404); const artifact = await this.artifactStore.get(organizationId, taskId, artifactId); if (!artifact) throw new TaskError('artifact_not_found', '附件不存在或无权访问。', 404); return artifact; @@ -3786,12 +5218,26 @@ export class TaskService { async listTasksPage( organizationId: string, - options: { status?: string; limit?: number; offset?: number; search?: string; includeTotal?: boolean } = {} + options: { + status?: string; + limit?: number; + offset?: number; + search?: string; + includeTotal?: boolean; + archive?: 'active' | 'archived' | 'all'; + access?: TaskAccessScope; + } = {} ): Promise { const status = text(options.status).trim(); const search = text(options.search).trim(); const params: unknown[] = [organizationId]; - let where = 't.organization_id = $1'; + 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'`; + } + if (options.archive === 'archived') where += ' AND t.archived_at IS NOT NULL'; + else if (options.archive !== 'all') where += ' AND t.archived_at IS NULL'; if (status) { if (status === 'active') { // This endpoint feeds the browser's normal plugin-result poller. @@ -3812,7 +5258,7 @@ export class TaskService { params.push(groupedStatuses); where += ` AND t.status = ANY($${params.length}::text[])`; } - } else { + } else if (options.archive !== 'archived') { // Cancelled tasks remain durable for audit/reconciliation, but they are // not active work items and must not reappear in the operator task list. where += " AND t.status <> 'cancelled'"; @@ -3845,11 +5291,14 @@ export class TaskService { const listResult = await getPool(this.config).query( `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, + 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, t.error_summary, t.error_summary_at, t.success_receipt, t.success_receipt_at, t.handoff_status, t.confirmation_mode, t.confirmed_at, + t.archived_at, t.archive_reason, t.business_route_id, t.input_contract_version, t.parser_mode, t.parser_config_revision, t.parser_engine_affinity, t.parser_engine, t.parser_version, t.parser_fallback_reason, @@ -3869,6 +5318,8 @@ export class TaskService { FROM tasks t 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 archiver ON archiver.id = t.archived_by WHERE ${where} ORDER BY t.created_at DESC, t.id DESC LIMIT $${limitParam} OFFSET $${offsetParam}`, listParams @@ -3934,6 +5385,7 @@ export class TaskService { `WITH candidate AS ( SELECT t.id FROM tasks t WHERE t.status = 'parse_queued' + AND t.archived_at IS NULL AND NOT (t.task_id = ANY($3::text[])) AND ( t.business_route_id IS NULL @@ -3979,8 +5431,10 @@ export class TaskService { ); await client.query( `INSERT INTO agent_session_messages - (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, status, idempotency_key) - SELECT $1, s.id, t.id, 1, 'user', t.original_text_ciphertext, 'queued', $3 + (organization_id, agent_session_id, task_id, turn_no, role, content_ciphertext, + status, idempotency_key, actor_user_id, input_source) + SELECT $1, s.id, t.id, 1, 'user', t.original_text_ciphertext, 'queued', $3, + t.created_by, CASE WHEN t.source = 'agentbus' THEN 'agentbus' ELSE 'manual' END FROM tasks t JOIN agent_sessions s ON s.task_id = t.id WHERE t.id = $2 @@ -4315,13 +5769,36 @@ export class TaskService { throw new TaskError('stale_parse_result', '解析结果已过期,已忽略迟到结果。'); } await this.recordParseDecision(client, row, resultObject, attemptNo, parseDecision); + const taskAuthorizationContext = this.taskAuthorizationContext(row, context.requestId); + const taskAuthorization = await this.businessAuthorizationDecision( + client, + taskAuthorizationContext, + businessRouteById(row.business_route_id)?.routeId || null + ); + if (!taskAuthorization.allowed) { + await this.audit( + client, + taskAuthorizationContext, + 'task.business_authorization_denied', + taskAuthorization.routeId || 'unclassified', + { + route_id: taskAuthorization.routeId, + directive: taskAuthorization.directive, + authorization_revision: taskAuthorization.authorizationRevision, + phase: 'automatic_confirmation', + no_plugin_dispatch: true, + no_erp_write: true + }, + 'business_route' + ); + } const organizationSettings = await client.query( 'SELECT automation_enabled FROM organizations WHERE id = $1', [row.organization_id] ); const automationEnabled = organizationSettings.rows[0]?.automation_enabled === true || text(organizationSettings.rows[0]?.automation_enabled) === 'true'; - const automatic = shouldAutomaticallyConfirm({ + const automatic = taskAuthorization.allowed && shouldAutomaticallyConfirm({ needsInput, blocked, source: row.source, @@ -4504,19 +5981,13 @@ export class TaskService { return { row: updatedRow, event }; }); this.notify(outcome.event); - return this.getTask(context.organizationId, taskId); + return this.getTask(context.organizationId, taskId, context); } async confirmTask(context: TaskContext, taskId: string): Promise { const outcome = await withTransaction(this.config, async (client) => { - const lookup = await client.query( - `SELECT * FROM tasks - WHERE organization_id = $1 AND task_id = $2 - FOR UPDATE`, - [context.organizationId, taskId] - ); - if (!lookup.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); - const row = lookup.rows[0] as Record; + const row = await this.lockTaskForAccess(client, context, taskId); + await this.assertTaskCreatorBusinessAuthorizationInTransaction(client, row, context.requestId); if (row.status !== 'awaiting_confirmation') { throw new TaskError('invalid_transition', `任务当前状态为 ${row.status},不能确认 Agent 结果并提交下一步。`); } @@ -4568,7 +6039,7 @@ export class TaskService { return { row: updatedRow, event }; }); this.notify(outcome.event); - return this.getTask(context.organizationId, taskId); + return this.getTask(context.organizationId, taskId, context); } async claimForBrowser(context: TaskContext, taskId: string, connectionId: string): Promise { @@ -4579,12 +6050,9 @@ export class TaskService { [context.organizationId] ); if (!organization.rowCount) throw new TaskError('organization_not_found', '组织不存在。', 404); - const lookup = await client.query( - 'SELECT * FROM tasks WHERE organization_id = $1 AND task_id = $2 FOR UPDATE', - [context.organizationId, taskId] - ); - if (!lookup.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); - const row = lookup.rows[0] as Record; + const row = await this.lockTaskForAccess(client, context, taskId); + await this.assertTaskCreatorBusinessAuthorizationInTransaction(client, row, context.requestId); + await this.requireBrowserConnection(client, context, connectionId); const existingAttempt = await client.query( `SELECT id, status, response_hash FROM task_attempts @@ -4624,7 +6092,7 @@ export class TaskService { throw new TaskError('execution_attempt_exists', '任务已有 ERP 执行记录,已阻止再次下发。'); } const activeExecutions = await client.query( - `SELECT t.id, t.task_id, t.status, a.id AS execution_id + `SELECT t.id, t.task_id, t.status, t.created_by, t.source, 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 @@ -4643,6 +6111,28 @@ export class TaskService { WHERE organization_id = $1 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' + ) + OR EXISTS ( + SELECT 1 + FROM users creator + JOIN user_business_route_authorizations route_grant + ON route_grant.organization_id = creator.organization_id + AND route_grant.user_id = creator.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') + ) + ) ORDER BY created_at ASC, id ASC FOR UPDATE`, [context.organizationId] @@ -4654,10 +6144,14 @@ 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) + }) ? text(activeOther.task_id) : null; await this.audit(client, context, 'task.browser_queued', taskId, { connection_id: connectionId, queue_position: queuePosition, - active_task_id: activeOther ? text(activeOther.task_id) : null + active_task_id: activeTaskId }); return { row, @@ -4666,7 +6160,7 @@ export class TaskService { executionId: '', queueStatus: 'waiting' as const, queuePosition, - activeTaskId: activeOther ? text(activeOther.task_id) : null + activeTaskId }; } const attempt = await client.query( @@ -4714,7 +6208,7 @@ export class TaskService { }; }); if (outcome.event) this.notify(outcome.event); - const task = await this.getTask(context.organizationId, taskId); + const task = await this.getTask(context.organizationId, taskId, context); return { task, claimed: outcome.claimed, @@ -4760,12 +6254,8 @@ export class TaskService { let transactionCommitted = false; try { const outcome = await withTransaction(this.config, async (client) => { - const lookup = await client.query( - 'SELECT * FROM tasks WHERE organization_id = $1 AND task_id = $2 FOR UPDATE', - [context.organizationId, taskId] - ); - if (!lookup.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); - const row = lookup.rows[0] as Record; + const row = await this.lockTaskForAccess(client, context, taskId); + await this.requireBrowserConnection(client, context, connectionId); const attempt = await client.query( `SELECT * FROM task_attempts WHERE id = $1 AND task_id = $2 AND phase = 'erp' @@ -4942,7 +6432,7 @@ export class TaskService { }); transactionCommitted = true; if (outcome.event) this.notify(outcome.event); - return this.getTask(context.organizationId, taskId); + return this.getTask(context.organizationId, taskId, context); } catch (error) { if (!transactionCommitted && uploadedArtifacts.length && this.artifactStore.cleanup) { await this.artifactStore.cleanup(uploadedArtifacts); @@ -5041,7 +6531,7 @@ export class TaskService { return { row: updatedRow, event }; }); if (outcome.event) this.notify(outcome.event); - return this.getTask(context.organizationId, taskId); + return this.getTask(context.organizationId, taskId, context); } private async expireExecutionTask( @@ -5357,124 +6847,99 @@ export class TaskService { return maintained; } - async hardDeleteTask(context: TaskContext, taskId: string): Promise<{ task_id: string; deleted: true }> { - const result = await this.hardDeleteTasks(context, [taskId]); - return { - task_id: result.task_ids[0] || taskId, - deleted: true - }; + async archiveTask(context: TaskContext, taskId: string, reason = ''): Promise { + await this.archiveTasks(context, [taskId], reason); + return this.getTask(context.organizationId, taskId, context); } - async hardDeleteTasks( + async archiveTasks( context: TaskContext, - taskIds: string[] - ): Promise<{ task_ids: string[]; deleted_count: number; deleted: true }> { + taskIds: string[], + reason = '' + ): Promise<{ task_ids: string[]; archived_count: number }> { const normalizedTaskIds = [...new Set(taskIds.map((taskId) => text(taskId).trim()).filter(Boolean))]; - if (!normalizedTaskIds.length) { - throw new TaskError('invalid_task_ids', '请至少选择一个待删除任务。', 400); - } + if (!normalizedTaskIds.length) throw new TaskError('invalid_task_ids', '请至少选择一个待归档任务。', 400); + const archiveReason = text(reason).trim().slice(0, 500) || null; const outcome = await withTransaction(this.config, async (client) => { const lookup = await client.query( - `SELECT id, task_id - FROM tasks + `SELECT * FROM tasks WHERE organization_id = $1 AND task_id = ANY($2::text[]) + AND ($3::boolean = false OR (created_by = $4 AND source = 'manual')) ORDER BY task_id FOR UPDATE`, - [context.organizationId, normalizedTaskIds] - ); - const foundTaskIds = new Set( - (lookup.rows as Record[]).map((row) => text(row.task_id)) + [context.organizationId, normalizedTaskIds, this.isOwnerRestrictedUser(context), context.userId || null] ); + const rows = lookup.rows as Record[]; + const foundTaskIds = new Set(rows.map((row) => text(row.task_id))); const missingTaskIds = normalizedTaskIds.filter((taskId) => !foundTaskIds.has(taskId)); - if (missingTaskIds.length) { - throw new TaskError( - 'task_not_found', - `以下任务不存在或已被删除:${missingTaskIds.join('、')}`, - 404 - ); + if (missingTaskIds.length) throw new TaskError('task_not_found', '任务不存在。', 404); + const active = rows.find((row) => ( + ['parse_queued', 'parse_running', 'confirmed', 'queued', 'accepted', 'running'].includes(text(row.status)) + || ['awaiting_handoff', 'accepted', 'running'].includes(text(row.handoff_status)) + )); + if (active) { + throw new TaskError('task_archive_blocked', `任务 ${text(active.task_id)} 正在处理或等待 ERP 执行,不能归档。`, 409); } - - const artifactLookup = await client.query( - `SELECT a.id, a.organization_id, t.task_id AS external_task_id, - a.execution_id, a.artifact_index, a.artifact_type, - a.file_name, a.content_type, a.byte_size, a.sha256, - a.storage_backend, a.storage_key, a.created_at - FROM task_artifacts a - JOIN tasks t ON t.id = a.task_id - WHERE t.organization_id = $1 - AND t.task_id = ANY($2::text[]) - ORDER BY a.id - FOR UPDATE OF a`, - [context.organizationId, normalizedTaskIds] - ); - const artifacts = (artifactLookup.rows as Record[]).map((row): StoredTaskArtifact => ({ - id: text(row.id), - organization_id: text(row.organization_id), - task_id: text(row.external_task_id), - execution_id: text(row.execution_id), - artifact_index: Number(row.artifact_index || 0), - type: text(row.artifact_type), - file_name: text(row.file_name), - content_type: text(row.content_type), - byte_size: Number(row.byte_size || 0), - sha256: text(row.sha256), - storage_backend: text(row.storage_backend || 'database'), - storage_key: text(row.storage_key) || null, - public_url: null, - created_at: new Date(String(row.created_at)).toISOString() - })); - - // These records are intentionally not foreign-keyed to tasks because - // they are append-only platform infrastructure. A hard task delete - // must remove their task-scoped rows explicitly in the same transaction. - await client.query( - `DELETE FROM audit_events - WHERE organization_id = $1 - AND entity_type = 'task' - AND entity_id = ANY($2::text[])`, - [context.organizationId, normalizedTaskIds] - ); - await client.query( - `DELETE FROM outbox_events - WHERE organization_id = $1 - AND aggregate_type = 'task' - AND aggregate_id = ANY($2::text[])`, - [context.organizationId, normalizedTaskIds] - ); - - const deleted = await client.query( - `DELETE FROM tasks - WHERE organization_id = $1 - AND task_id = ANY($2::text[]) - RETURNING task_id`, - [context.organizationId, normalizedTaskIds] - ); - return { - result: { - task_ids: normalizedTaskIds, - deleted_count: deleted.rowCount || 0, - deleted: true as const - }, - artifacts - }; + const events: TaskEvent[] = []; + let archivedCount = 0; + for (const row of rows) { + if (row.archived_at) continue; + const updated = await client.query( + `UPDATE tasks + SET archived_at = now(), archived_by = $1, archive_reason = $2, updated_at = now() + WHERE id = $3 + RETURNING *`, + [context.userId || null, archiveReason, row.id] + ); + const updatedRow = updated.rows[0] as Record; + events.push(await this.emitEvent(client, updatedRow, { + status: text(updatedRow.status), + stage: text(updatedRow.stage), + message: text(updatedRow.message), + payload: { archived: true } + }, context.userId || null)); + await this.audit(client, context, 'task.archived', text(row.task_id), { + reason_present: Boolean(archiveReason) + }); + archivedCount += 1; + } + return { events, archivedCount }; }); - if (outcome.artifacts.length && this.artifactStore.cleanup) { - await this.artifactStore.cleanup(outcome.artifacts); - } - return outcome.result; + for (const event of outcome.events) this.notify(event); + return { task_ids: normalizedTaskIds, archived_count: outcome.archivedCount }; + } + + async restoreTask(context: TaskContext, taskId: string): Promise { + const outcome = await withTransaction(this.config, async (client) => { + const row = await this.lockTaskForAccess(client, context, taskId, { allowArchived: true }); + if (!row.archived_at) return { event: null }; + const updated = await client.query( + `UPDATE tasks + SET archived_at = NULL, archived_by = NULL, archive_reason = NULL, updated_at = now() + WHERE id = $1 + RETURNING *`, + [row.id] + ); + const updatedRow = updated.rows[0] as Record; + const event = await this.emitEvent(client, updatedRow, { + status: text(updatedRow.status), + stage: text(updatedRow.stage), + message: text(updatedRow.message), + payload: { archived: false, restored: true } + }, context.userId || null); + await this.audit(client, context, 'task.restored', taskId); + return { event }; + }); + if (outcome.event) this.notify(outcome.event); + return this.getTask(context.organizationId, taskId, context); } async cancelTask(context: TaskContext, taskId: string): Promise { const outcome = await withTransaction(this.config, async (client) => { - const lookup = await client.query( - 'SELECT * FROM tasks WHERE organization_id = $1 AND task_id = $2 FOR UPDATE', - [context.organizationId, taskId] - ); - if (!lookup.rowCount) throw new TaskError('task_not_found', '任务不存在。', 404); - const row = lookup.rows[0] as Record; + const row = await this.lockTaskForAccess(client, context, taskId); if (['completed', 'reconciliation_pending'].includes(text(row.status))) { - throw new TaskError('cancel_blocked', '任务已完成或待回查,不能通过删除回滚。'); + throw new TaskError('cancel_blocked', '任务已完成或待回查,不能通过取消回滚。'); } const erpAttempt = await client.query( `SELECT 1 FROM task_attempts @@ -5483,7 +6948,7 @@ export class TaskService { [row.id] ); if (erpAttempt.rowCount) { - throw new TaskError('cancel_blocked_execution_started', '任务已交给 ERP 执行,删除不能证明未写入;请保留记录并等待回执或人工回查。'); + throw new TaskError('cancel_blocked_execution_started', '任务已交给 ERP 执行,取消不能证明未写入;请保留记录并等待回执或人工回查。'); } const updated = await client.query( `UPDATE tasks @@ -5510,28 +6975,35 @@ export class TaskService { return { row: updatedRow, event }; }); this.notify(outcome.event); - return this.getTask(context.organizationId, taskId); + return this.getTask(context.organizationId, taskId, context); } async heartbeat(context: TaskContext, connectionId: string, extensionVersion: string, metadata: Record = {}): Promise { - await getPool(this.config).query( + 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 user_id = EXCLUDED.user_id, extension_version = EXCLUDED.extension_version, - status = 'connected', last_seen_at = now(), metadata = EXCLUDED.metadata`, + 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 eventsSince(organizationId: string, since = 0): Promise { + 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, e.status, e.stage, e.message, e.payload, e.created_at + `SELECT e.id, e.organization_id, t.task_id, t.created_by, 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')) ORDER BY e.id ASC LIMIT 500`, - [organizationId, since] + [organizationId, since, isTaskOwnerRestricted(access?.role), access?.userId || null] ); return result.rows.map((row) => ({ id: Number(row.id), @@ -5541,7 +7013,9 @@ export class TaskService { stage: text(row.stage), message: text(row.message), payload: jsonObject(row.payload), - created_at: new Date(String(row.created_at)).toISOString() + created_at: new Date(String(row.created_at)).toISOString(), + owner_user_id: text(row.created_by) || null, + task_source: normalizeTaskSource(row.source) })); } } diff --git a/control-plane/test/account-authorization.test.ts b/control-plane/test/account-authorization.test.ts new file mode 100644 index 0000000..1631183 --- /dev/null +++ b/control-plane/test/account-authorization.test.ts @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +async function source(path: string): Promise { + return readFile(new URL(path, import.meta.url), 'utf8'); +} + +test('account migration adds roles, actor attribution, and reversible archive without purging history', async () => { + const sql = await source('../migrations/015_account_roles_and_task_audit.sql'); + assert.match(sql, /CHECK \(role IN \('admin', 'user'\)\)/); + assert.match(sql, /must_change_password boolean NOT NULL DEFAULT false/); + assert.match(sql, /password_changed_at timestamptz NOT NULL DEFAULT now\(\)/); + assert.match(sql, /idempotency_keys[\s\S]+actor_user_id uuid REFERENCES users\(id\)/); + assert.match(sql, /idempotency_keys_actor_unique_idx/); + assert.match(sql, /idempotency_keys_system_unique_idx/); + assert.match(sql, /actor_user_id uuid REFERENCES users\(id\)/); + assert.match(sql, /input_source IN \('manual', 'agentbus', 'reparse', 'system'\)/); + assert.match(sql, /task_input_attachments[\s\S]+created_by uuid REFERENCES users\(id\)/); + assert.match(sql, /archived_at timestamptz/); + assert.match(sql, /archived_by uuid REFERENCES users\(id\)/); + assert.doesNotMatch(sql, /DELETE\s+FROM/i); +}); + +test('team-lead migration adds the role and bounded dashboard indexes without a tenant concept', async () => { + const sql = await source('../migrations/016_team_lead_operations_dashboard.sql'); + assert.match(sql, /CHECK \(role IN \('admin', 'team_lead', 'user'\)\)/); + assert.match(sql, /tasks_operations_dashboard_created_idx/); + assert.match(sql, /tasks_operations_dashboard_actor_idx/); + assert.match(sql, /tasks_operations_dashboard_status_idx/); + assert.match(sql, /tasks_operations_dashboard_business_idx/); + assert.match(sql, /WHERE source = 'manual'/); + assert.doesNotMatch(sql, /CREATE TABLE\s+(?:organizations|tenants)/i); + assert.doesNotMatch(sql, /DELETE\s+FROM/i); +}); + +test('business authorization migration adds a fail-closed per-user allowlist for all registered routes', async () => { + const sql = await source('../migrations/017_user_business_route_authorizations.sql'); + assert.match(sql, /business_authorization_revision integer NOT NULL DEFAULT 0/); + assert.match(sql, /CREATE TABLE IF NOT EXISTS user_business_route_authorizations/); + assert.match(sql, /PRIMARY KEY \(organization_id, user_id, route_id\)/); + assert.match(sql, /granted_by uuid REFERENCES users\(id\)/); + assert.match(sql, /FOREIGN KEY \(organization_id, user_id\)[\s\S]+REFERENCES users \(organization_id, id\)/); + assert.match(sql, /FOREIGN KEY \(organization_id, granted_by\)[\s\S]+REFERENCES users \(organization_id, id\)/); + for (const routeId of [ + 'team_order_create', + 'passenger_list_import_independent', + 'arrangement_hotel_create', + 'order_update_independent', + 'order_cancel', + 'confirmation_export' + ]) { + assert.match(sql, new RegExp(`'${routeId}'`)); + } + assert.doesNotMatch(sql, /INSERT INTO user_business_route_authorizations[\s\S]+SELECT[\s\S]+FROM users/i); +}); + +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'), + source('../src/server.ts') + ]); + assert.match(auth, /export type AuthRole = 'admin' \| 'team_lead' \| 'user'/); + assert.match(auth, /role: normalizeRole\(row\.role\)/); + assert.match(auth, /argon2\.hash\([^;]+type: argon2\.argon2id/s); + assert.match(auth, /self_lockout_forbidden/); + assert.match(auth, /last_admin_protected/); + assert.match(auth, /UPDATE sessions SET revoked_at = now\(\)/); + assert.match(auth, /must_change_password = false/); + assert.match(auth, /account\.password_reset/); + assert.match(auth, /account\.password_changed/); + 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\)/); + assert.match(server, /password_change_required/); + const publicUser = server.slice(server.indexOf('function publicUser'), server.indexOf('async function loadExternalParser')); + assert.match(publicUser, /must_change_password/); + assert.doesNotMatch(publicUser, /organization/); +}); + +test('administrators manage task-type grants and manual intake enforces them before parsing or ERP dispatch', async () => { + const [auth, tasks, server] = await Promise.all([ + source('../src/auth.ts'), + source('../src/task-service.ts'), + source('../src/server.ts') + ]); + assert.match(auth, /authorized_business_route_ids: BusinessRouteId\[\]/); + assert.match(auth, /async setBusinessRouteAuthorizations\(/); + assert.match(auth, /business_authorization_revision_conflict/); + assert.match(auth, /account\.business_authorizations_updated/); + assert.match(auth, /admin_business_authorization_fixed/); + assert.match(server, /task_types: BUSINESS_ROUTES\.map/); + assert.match(server, /app\.put\('\/api\/accounts\/:userId\/business-authorizations'[\s\S]+requireAdminMutationSession\(request\)/); + assert.match(tasks, /export function canExecuteBusinessRoute/); + assert.match(tasks, /task\.business_authorization_denied/); + assert.match(tasks, /business_type_unresolved/); + assert.match(tasks, /当前账号未授权“\$\{route\.directive\}”业务,已禁止执行/); + assert.match(tasks, /no_parse: true/); + assert.match(tasks, /no_plugin_dispatch: true/); + assert.match(tasks, /no_erp_write: true/); + const createTask = tasks.slice(tasks.indexOf('async createTask('), tasks.indexOf('async ingestMessage(')); + assert.match(createTask, /requireBusinessAuthorization\(context, requestedRouteId/); + assert.match(createTask, /assertBusinessAuthorizationInTransaction/); + const ingestMessage = tasks.slice(tasks.indexOf('async ingestMessage('), tasks.indexOf('async getTask(')); + assert.match(ingestMessage, /requireBusinessAuthorization\(context, preflightRouteId/); + assert.match(ingestMessage, /assertBusinessAuthorizationInTransaction/); + const attachment = tasks.slice( + tasks.indexOf('async attachPassengerRosterAttachment('), + tasks.indexOf('async createTask(') + ); + assert.match(attachment, /requireBusinessAuthorization\(context, target\.routeId/); + assert.match(attachment, /assertBusinessAuthorizationInTransaction/); + for (const transition of ['confirmTask', 'claimForBrowser']) { + const start = tasks.indexOf(`async ${transition}(`); + assert.notEqual(start, -1); + assert.match(tasks.slice(start, start + 8_000), /assertTaskCreatorBusinessAuthorizationInTransaction/); + } + const parseResult = tasks.slice(tasks.indexOf('async applyParseResult('), tasks.indexOf('async confirmTask(')); + assert.match(parseResult, /taskAuthorization\.allowed && shouldAutomaticallyConfirm/); +}); + +test('operations dashboard is leadership-gated, business-facing, searchable, and separate from normal task authority', async () => { + const [tasks, server] = await Promise.all([ + source('../src/task-service.ts'), + source('../src/server.ts') + ]); + assert.match(tasks, /canViewOperationsDashboard[\s\S]+role === 'admin' \|\| role === 'team_lead'/); + assert.match(tasks, /isTaskOwnerRestricted[\s\S]+role === 'team_lead' \|\| role === 'user'/); + assert.match(tasks, /async listOperationsDashboard[\s\S]+t\.source = 'manual'/); + assert.match(tasks, /operations_dashboard_range_too_large/); + assert.match(tasks, /businessRouteId\?: string/); + assert.match(tasks, /operations_dashboard_business_invalid/); + assert.match(tasks, /LIMIT 2001/); + assert.match(tasks, /operations_dashboard_search_scope_too_large/); + assert.match(tasks, /content_ciphertext[\s\S]+decryptText\(this\.config, row\.content_ciphertext/); + assert.match(tasks, /operationsDashboardSearchMatches[\s\S]+projection\.instruction[\s\S]+projection\.result/); + assert.match(tasks, /timezone\('Asia\/Shanghai', t\.created_at\)/); + assert.match(tasks, /days,[\s\S]+businesses,[\s\S]+business_options/); + assert.match(tasks, /instructionPreview\.length > 360/); + const detailType = tasks.slice( + tasks.indexOf('export interface PublicOperationsDashboardTaskDetail'), + tasks.indexOf('export interface PublicOperationsDashboardPage') + ); + assert.match(detailType, /creator: PublicOperationsDashboardActor/); + assert.match(detailType, /instructions: PublicOperationsDashboardInstruction\[\]/); + assert.match(detailType, /result_summary: string/); + assert.doesNotMatch(detailType, /events|stage|operation|parse_response|execution_result|download_url/); + const detailMethod = tasks.slice( + tasks.indexOf('async getOperationsDashboardTask('), + tasks.indexOf('async listAuditEvents(') + ); + assert.match(detailMethod, /source = 'manual'/); + assert.match(detailMethod, /getTaskInputHistory/); + assert.match(detailMethod, /instructions,[\s\S]+attachments:/); + assert.doesNotMatch(detailMethod, /this\.getTask\(/); + assert.match(server, /app\.get\('\/api\/operations-dashboard'[\s\S]+requireLeadershipSession\(request\)/); + assert.match(server, /business_route_id: z\.string\(\)\.trim\(\)\.max\(120\)\.optional\(\)/); + assert.match(server, /businessRouteId: query\.business_route_id/); + assert.match(server, /app\.get\('\/api\/operations-dashboard\/tasks\/:taskId'[\s\S]+requireLeadershipSession\(request\)/); + assert.match(server, /read_only: true/); + assert.doesNotMatch(server, /app\.(?:post|put|patch|delete)\('\/api\/operations-dashboard/); +}); + +test('ordinary task access is enforced across reads, mutations, artifacts, events, and browser connections', async () => { + const [tasks, server] = await Promise.all([ + source('../src/task-service.ts'), + source('../src/server.ts') + ]); + assert.match(tasks, /created_by = \$4 AND source = 'manual'/); + assert.match(tasks, /private async lockTaskForAccess/); + for (const mutation of ['reparseTaskWithAi', 'confirmTask', 'claimForBrowser', 'recordExecutionResult', 'cancelTask']) { + const start = tasks.indexOf(`async ${mutation}(`); + assert.notEqual(start, -1, `${mutation} exists`); + 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 getTaskInputHistory[\s\S]+actor_user_id/); + assert.match(tasks, /WHERE organization_id = \$1 AND user_id = \$2 AND connection_id = \$3/); + assert.match(tasks, /WHERE browser_connections\.user_id = EXCLUDED\.user_id/); + 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/); + assert.match(server, /tasks\.listTasksPage[\s\S]+access: contextFor\(session, request\)/); + assert.match(server, /tasks\.getTaskArtifact[\s\S]+contextFor\(session, request\)/); + assert.match(server, /tasks\.eventsSince\(session\.user\.organizationId, since, contextFor\(session, request\)\)/); +}); + +test('operator UI exposes role-aware accounts, business drill-through, original input history, and reversible archive', async () => { + const [app, index, retention] = await Promise.all([ + source('../../LianSyn-platform/app.js'), + source('../../LianSyn-platform/index.html'), + source('../src/retention.ts') + ]); + assert.match(index, /href="\/accounts"/); + assert.match(index, /href="\/audit"/); + assert.match(index, /href="\/operations-dashboard"/); + assert.match(index, /id="passwordChangeForm"/); + assert.match(index, /id="accountForm"/); + assert.match(index, /id="accountAuthorizationPanel"/); + assert.match(index, /id="accountAuthorizationTypes"/); + assert.match(index, /id="accountAuthorizationSave"/); + assert.match(index, /value="team_lead">组长/); + assert.match(index, /id="operationsDashboardFilters"/); + assert.match(index, /id="operationsDashboardBusiness"/); + assert.match(index, /id="operationsDashboardDays"/); + assert.match(index, /id="operationsDashboardBusinesses"/); + assert.match(index, /姓名、指令内容、完成结果、团号或订单号/); + assert.match(index, /id="operationsDashboardDetail"/); + assert.match(index, /id="historyArchiveInput"/); + assert.match(app, /authUser\?\.role === 'admin'/); + assert.match(app, /crypto\.randomUUID/); + assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/input-history/); + assert.match(app, /创建人与原始输入审计/); + assert.match(app, /function renderAccountAuthorizationPanel/); + assert.match(app, /\/business-authorizations/); + assert.match(app, /当前默认不能执行任何业务/); + assert.match(app, /function canViewOperationsDashboard/); + assert.match(app, /\/api\/operations-dashboard\?/); + assert.match(app, /\/api\/operations-dashboard\/tasks\/\$\{encodeURIComponent\(taskId\)\}/); + assert.match(app, /business_route_id/); + assert.match(app, /data-operations-status|dataset\.operationsStatus/); + assert.match(app, /data-operations-day|dataset\.operationsDay/); + assert.match(app, /data-operations-business|dataset\.operationsBusiness/); + assert.match(app, /`指令:\$\{task\.instruction_preview/); + assert.match(app, /`结果:\$\{task\.result_summary/); + const dashboardDetailRenderer = app.slice( + app.indexOf('function renderOperationsDashboardDetail()'), + app.indexOf('async function syncOperationsDashboard()') + ); + assert.match(dashboardDetailRenderer, /操作人/); + assert.match(dashboardDetailRenderer, /指令内容/); + assert.match(dashboardDetailRenderer, /完成结果/); + assert.doesNotMatch(dashboardDetailRenderer, /任务生命周期|处理结果与技术上下文|renderTaskLifecycle|JSON\.stringify|task\.stage|parse_response|operation:/); + assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/); + assert.match(app, /\/api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/); + assert.doesNotMatch(app, /sendToExtension\('DELETE_TASK'/); + assert.match(retention, /SET archived_at = now\(\)/); + assert.doesNotMatch(retention, /DELETE FROM tasks/); + assert.doesNotMatch(retention, /DELETE FROM audit_events/); +}); diff --git a/control-plane/test/control-plane.test.ts b/control-plane/test/control-plane.test.ts index fcb2ebb..fc5abac 100644 --- a/control-plane/test/control-plane.test.ts +++ b/control-plane/test/control-plane.test.ts @@ -13,12 +13,16 @@ import { buildImmediateParserPromotionGates, classifyExpiredExecutionResult, classifyExecutionResult, + canAccessTask, + canExecuteBusinessRoute, + canViewOperationsDashboard, executionLifecycleFacts, failureSummary, executionStatusImmutable, executionUpdateAllowed, hasPrewriteNoErpEvidence, hasLifecycleTestContext, + isTaskOwnerRestricted, isLifecycleOperation, parseLifecycleFacts, prepareParsedOperationForConfirmation, @@ -41,6 +45,51 @@ test('message routing starts a new session for a business directive, not for a s assert.equal(isNewDirectiveMessage('数量改为 2'), false); }); +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 } + ]; + for (const item of cases) { + assert.equal(canAccessTask(item.access, { createdBy: item.createdBy, source: item.source }), item.allowed, item.name); + } + assert.equal(isTaskOwnerRestricted('admin'), false); + assert.equal(isTaskOwnerRestricted('team_lead'), true); + assert.equal(isTaskOwnerRestricted('user'), true); + assert.equal(canViewOperationsDashboard('admin'), true); + assert.equal(canViewOperationsDashboard('team_lead'), true); + assert.equal(canViewOperationsDashboard('user'), false); +}); + +test('business route authorization is an explicit allowlist for team leads and ordinary users', () => { + const routeId = 'arrangement_hotel_create' as const; + assert.equal(canExecuteBusinessRoute({ + role: 'admin', source: 'manual', routeId: null, authorizedRouteIds: [] + }), true, 'administrators retain all registered and unclassified manual intake'); + assert.equal(canExecuteBusinessRoute({ + role: 'team_lead', source: 'manual', routeId, authorizedRouteIds: [routeId] + }), true, 'team lead can use a granted route'); + assert.equal(canExecuteBusinessRoute({ + role: 'team_lead', source: 'manual', routeId, authorizedRouteIds: [] + }), false, 'team lead cannot use an ungranted route'); + assert.equal(canExecuteBusinessRoute({ + role: 'user', source: 'manual', routeId, authorizedRouteIds: [routeId] + }), true, 'ordinary user can use a granted route'); + assert.equal(canExecuteBusinessRoute({ + 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'); +}); + test('field encryption round-trips without storing plaintext', () => { const config = loadConfig({ NODE_ENV: 'test', @@ -262,7 +311,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, '014_task_input_attachments'); + assert.equal(REQUIRED_SCHEMA_VERSION, '017_user_business_route_authorizations'); assert.match(db, /schema_migrations/); assert.match(db, /databaseReadiness/); assert.match(db, /assertDatabaseSchema/); @@ -1176,8 +1225,8 @@ test('operator page has a login gate and uses the durable task API', async () => const inpage = await readFile(new URL('../../chrome-extension/ltjt-order-assistant/inpage.js', import.meta.url), 'utf8'); assert.match(index, /id="loginPanel"/); assert.match(index, /id="workbench"[^>]*hidden/); - assert.match(index, /styles\.css\?v=20260827-roster-attachment-1/); - assert.match(index, /app\.js\?v=20260830-team-order-validation-scope-1/); + assert.match(index, /styles\.css\?v=20260901-business-authorization-1/); + assert.match(index, /app\.js\?v=20260901-business-authorization-1/); assert.match(index, /id="statusDetailsPopover"/); assert.match(index, /id="statusDetailsRefresh"/); assert.match(app, /apiRequest\(`\/api\/tasks\?\$\{params\.toString\(\)\}`/); @@ -1289,7 +1338,7 @@ test('operator page has a login gate and uses the durable task API', async () => assert.match(app, /data-history-task-delete/); assert.match(app, /const historySelectedTaskIds = new Set\(\)/); assert.match(app, /async function deleteHistorySelectedTasks\(\)/); - assert.match(app, /apiRequest\('\/api\/tasks\/bulk-delete'/); + assert.match(app, /apiRequest\('\/api\/tasks\/bulk-archive'/); assert.match(app, /function renderHistoryBatchActions\(visibleTasks/); assert.match(app, /historySelectedTaskIds\.clear\(\)/); assert.match(app, /recorded_at/); @@ -1335,30 +1384,30 @@ test('operator page has a login gate and uses the durable task API', async () => assert.match(styles, /\.task-operation-review-card/); assert.match(styles, /\.task-important-message-card\.state-ok/); assert.match(app, /operation_contract_validation/); - assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}.*method: 'DELETE'/s); + assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/archive/); + assert.match(app, /api\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/restore/); assert.match(app, /const taskDeleteStates = new Map\(\)/); assert.match(app, /taskDeleteStates\.get\(task\.task_id\)/); assert.match(app, /taskDeleteStates\.set\(taskId, 'deleting'\)/); - assert.match(app, /deleteButton\.textContent = '删除中…'/); - assert.match(app, /void sendToExtension\('DELETE_TASK'/); - assert.doesNotMatch(app, /await sendToExtension\('DELETE_TASK'/); - assert.match(taskService, /async hardDeleteTask\(/); - assert.match(taskService, /async hardDeleteTasks\(/); - assert.match(taskService, /task_id = ANY\(\$2::text\[\]\)/); + assert.match(app, /deleteButton\.textContent = task\?\.archived_at \? '恢复中…' : '归档中…'/); + assert.doesNotMatch(app, /sendToExtension\('DELETE_TASK'/); + assert.match(taskService, /async archiveTask\(/); + assert.match(taskService, /async archiveTasks\(/); + assert.match(taskService, /async restoreTask\(/); + assert.match(taskService, /archived_at = now\(\), archived_by = \$1/); + assert.match(taskService, /SET archived_at = NULL, archived_by = NULL, archive_reason = NULL/); assert.match(taskService, /const missingTaskIds = normalizedTaskIds\.filter/); - assert.match(taskService, /DELETE FROM audit_events[\s\S]+entity_id = ANY\(\$2::text\[\]\)/); - assert.match(taskService, /DELETE FROM audit_events/); - const hardDeleteTasksSource = taskService.slice( - taskService.indexOf('async hardDeleteTasks('), - taskService.indexOf('async cancelTask(') - ); - assert.match(hardDeleteTasksSource, /FROM task_artifacts a/); - assert.match(hardDeleteTasksSource, /FOR UPDATE OF a/); - assert.match(hardDeleteTasksSource, /this\.artifactStore\.cleanup\(outcome\.artifacts\)/); + assert.doesNotMatch(taskService, /async hardDeleteTask\(/); + assert.doesNotMatch(taskService, /DELETE FROM audit_events/); + assert.doesNotMatch(taskService, /DELETE FROM tasks/); assert.match(server, /taskBulkDeleteSchema/); assert.match(server, /\.max\(100\)/); assert.match(server, /app\.post\('\/api\/tasks\/bulk-delete'/); + assert.match(server, /app\.post\('\/api\/tasks\/bulk-archive'/); assert.match(server, /app\.delete\('\/api\/tasks\/:taskId'/); + assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/archive'/); + assert.match(server, /app\.post\('\/api\/tasks\/:taskId\/restore'/); + assert.doesNotMatch(server, /tasks\.hardDelete/); assert.match(bridge, /status: 'deleted'/); assert.match(background, /LTJT_HARD_DELETE_TASK/); assert.doesNotMatch(app, /task-json-output|taskResponseJson/);