diff --git a/.env.production.example b/.env.production.example index 67ed33f..80e0ca3 100644 --- a/.env.production.example +++ b/.env.production.example @@ -40,6 +40,19 @@ OSS_ENDPOINT=oss-cn-guangzhou.aliyuncs.com OSS_BUCKET_NAME=one-feel-bucket OSS_REGION=cn-guangzhou OSS_KEY_PREFIX=liansyn-platform/attachments +# Unpacked Chrome extension updates. Keep false until every Windows Server +# profile has been bootstrapped from the shared ProgramData directory. +EXTENSION_AUTO_UPDATE_ENABLED=false +EXTENSION_UPDATE_OSS_KEY_PREFIX=liansyn-platform/chrome-extension +EXTENSION_WINDOWS_INSTALL_PATH=C:\ProgramData\LTJT\chrome-extension\ltjt-order-assistant +EXTENSION_UPDATE_MAX_PACKAGE_BYTES=15000000 +EXTENSION_UPDATE_COMMAND_TIMEOUT_SECONDS=600 +EXTENSION_UPDATE_POLL_INTERVAL_MS=5000 +# Use a RAM identity restricted to ecs:RunCommand and invocation-result reads. +# Do not reuse a broad OSS key merely because OSS is also configured above. +ALIBABA_CLOUD_ACCESS_KEY_ID=replace-with-restricted-ecs-access-key-id +ALIBABA_CLOUD_ACCESS_KEY_SECRET=replace-with-restricted-ecs-access-key-secret +ALIBABA_CLOUD_SECURITY_TOKEN= PARSER_REQUEST_TIMEOUT_MS=120000 PARSER_TOTAL_TIMEOUT_MS=180000 PARSER_MAX_EVENT_BYTES=65536 diff --git a/.project-docs/30-worklog/tasks/20260903-service-extension-update-8d42c6f1.md b/.project-docs/30-worklog/tasks/20260903-service-extension-update-8d42c6f1.md new file mode 100644 index 0000000..f1a8e90 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260903-service-extension-update-8d42c6f1.md @@ -0,0 +1,66 @@ +# Task: Integrate extension updates into local Windows service + +## Identity + +- Task ID: 20260903-service-extension-update-8d42c6f1 +- Mode: Feature +- Branch: codex/20260903-service-extension-update-8d42c6f1-service-extension-update +- Worktree: /Users/inmanx/Documents/lwltAPI-service-extension-update-8d42c6f1 +- Base commit: 500034bb6305c56271bc32918cb0edc885dbba44 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Extend the existing browser-extension heartbeat so the separate central Node service compares the loaded browser version with one approved OSS release, coordinates an idle-only update across multiple accounts on each Windows Server, and verifies the new live browser version afterward. +- Keep update orchestration inside the current control-plane service and use Alibaba Cloud ECS Cloud Assistant as the existing remote execution boundary; do not introduce a separately maintained LTJT updater process on each host. +- Preserve the current unpacked-extension installation model and converge every Chrome profile on one shared `C:\ProgramData\LTJT\chrome-extension\ltjt-order-assistant` source directory. Do not claim that unmanaged Windows Chrome can install a self-hosted CRX. + +## Intent And Constraints + +- Current source already reports `chrome.runtime.getManifest().version` through the platform page to `/api/connections/heartbeat`, where `browser_connections.extension_version` stores it. This is a browser-originated heartbeat, not a server-side Windows filesystem probe. +- An authenticated administrator may publish an approved versioned ZIP. The service must validate bounded ZIP entries and Windows-safe paths, Manifest identity/runtime wiring, a monotonically newer version and SHA-256; store a content-addressed private OSS object; issue only a short-lived HMAC download capability; stage it, retain one rollback copy, and replace only an explicitly configured ProgramData extension directory. +- Never update or reload while an ERP execution is active, accepted, write-started, submitted, uncertain, or awaiting reconciliation. The server must withhold execution readiness during a required update and require a post-reload heartbeat at the target version before declaring success. +- The first bootstrap extension release must know how to prove idle state and request an idle reload after the service has replaced its local files. Existing `0.5.166` and older profiles require one final manual move/load from the shared ProgramData directory before automatic publication is enabled. +- Do not inspect `.env` or secrets, invent signing credentials, write to OSS, call Alibaba Cloud APIs, replace local extension files, reload Chrome, restart/deploy services, access ERP, or mutate live tasks during repository implementation and tests. +- Feature mode owns only this task record and task implementation files; canonical release/update architecture remains an Integration Gate promotion candidate. + +## Outcome + +- Added migration `019_extension_host_updates`: non-admin accounts can bind an ECS region/instance pair; private extension releases and one host-level update state per organization/instance persist in PostgreSQL. Required-migration readiness now includes `019`. +- Added default-off production configuration for extension publication, bounded package size, shared Windows install path, Cloud Assistant timeout/polling and a separately scoped Alibaba Cloud ECS credential. Enabling the feature requires complete OSS/ECS configuration and HTTPS `APP_ORIGIN` in production. +- Added an administrator-only `/accounts` release panel and ECS host mapping controls. Release publication validates the ZIP before storage, rejects unsafe/duplicate/encrypted/symlink paths and incorrect Manifest wiring, rejects downgrade or same-version/different-hash publication, stores a SHA-addressed private OSS object, records an audit event, and exposes no OSS credential or object URL. +- Added short-lived HMAC package downloads through the control plane. The service re-reads the private OSS object and verifies its stored byte count and SHA-256 before returning it; diagnostics replace the capability-bearing URL path with `:token`. +- Added the official Alibaba Cloud ECS SDK integration for one-shot `RunPowerShellScript` commands (`KeepCommand=false`). The command is bounded below the documented 24 KB Base64 limit, runs only under the configured `ProgramData\LTJT` subtree, refuses downgrades, verifies package SHA-256 and Manifest name/version, stages the new directory, keeps `.previous`, rolls back a failed switch, and emits a required completion marker. +- Made Cloud Assistant dispatch crash- and ambiguity-safe: each persisted attempt reconstructs the same signed download URL and 64-character `ClientToken`; an unknown API outcome remains `running` and reuses the same attempt instead of launching a second command. The absolute command deadline survives service restarts, definite failures retry at most three total attempts, and an already-started older release finishes before the host jumps directly to the newest active release. +- Extended the browser heartbeat with the extension's in-memory and durable safety proof. The server independently checks every account mapped to the same ECS instance for active leases, accepted/running ERP attempts, reconciliation tasks, and recent unsafe browser workers under the same organization lock used by task claiming. Whichever operation wins the lock blocks the other, so a new ERP execution cannot race file deployment. +- Added a second server-side claim gate against the current active release. A browser below the target version cannot receive a new ERP task when the update is waiting, running, deployed, unconfigured or failed; a target-version heartbeat is required before normal execution resumes. Existing unfinished host states also remain blocking if the feature is disabled during rollout. +- Added extension `0.5.167` safety/reload protocol and platform handling. The background worker rejects reload while any task is running, has crossed `write_started/submitted/uncertain`, or awaits reconciliation; after deployment the platform requests `chrome.runtime.reload()`, refreshes the page, and the next target-version heartbeat verifies the host. Multiple profiles sharing one host are reloaded and version-gated independently while sharing one deployment. +- Preserved `0.5.166`'s immediate-first, conditional 100 ms ERP readiness wait with no fixed fast-path delay. Synchronized the extension source, platform minimum, mappings, release gate, regression assertions and `dist/release-manifest.json` at `0.5.167`. +- Built `dist/ltjt-order-assistant-0.5.167.zip` with SHA-256 `6822ec660097a8889da8ea4fa22d4091467aa022067bff3bee2ac5406864b679`. Archived the superseded `0.5.166` ZIP and matching manifest under `archive/releases/2026-09-03/`; no unique historical release was discarded. +- Documented the central-server/multi-Windows-host topology, network boundary, RAM least-privilege requirement, account-to-instance grouping, initial bootstrap and future publication flow in `control-plane/README.md` and the extension README. +- No `.env` or secret was inspected; no OSS object was written, Alibaba Cloud API called, Windows directory replaced, Chrome runtime reloaded, service deployed/restarted, ERP accessed, or live task mutated. + +## Verification + +- `npm test`: passed (`test:control-plane` 166/166; `test:legacy` 270/270). +- `npm run check:repo`: 10/10 passed, including exact extension ZIP/source file comparison and release-manifest hash verification. +- `npm run build`: passed. +- Focused extension-update tests cover numeric version ordering, ZIP identity/unsafe Windows path rejection, HMAC tamper/expiry, ProgramData/hash/staging/rollback/no-downgrade script boundaries, Cloud Assistant terminal status/completion marker handling, and default-off production configuration. +- `npm audit --omit=dev`: reported 7 inherited findings (4 high, 3 moderate) in the pre-existing Fastify/static/AJV/ExcelJS dependency paths; the new Alibaba Cloud SDK path was not among the reported vulnerable paths. No unrelated dependency upgrade or audit fix was applied in this Feature task. + +## Follow-ups + +- Integration/deployment remains separately authorized: merge this feature, back up and apply migration `019`, configure the restricted RAM identity and OSS/HTTPS values, then restart the central service. None of those live actions occurred here. +- Before setting `EXTENSION_AUTO_UPDATE_ENABLED=true`, manually place/load bootstrap `0.5.167` from the shared ProgramData path in every existing Windows/Chrome profile and confirm its heartbeat. This is the last per-profile manual rollout; `0.5.166` cannot safely bootstrap the new protocol itself. +- Validate Cloud Assistant Agent status and central `APP_ORIGIN` HTTPS reachability on one non-production Windows Server, publish a higher test version, and observe waiting → running → deployed → reload → verified before widening the ECS account mappings. +- Address the inherited npm audit findings in a separately scoped dependency-security task; they were present in the base lockfile paths and are not specific to extension updating. + +## Promotion Candidates + +- Target documents: canonical system architecture, release/distribution decision record, current-state snapshot, and production operations guidance. +- Proposed durable fact: the production topology has one separate central control plane and multiple Alibaba ECS Windows Server browser hosts. The control plane owns version publication and state; private OSS owns immutable package bytes; ECS Cloud Assistant supplies one-shot host execution; each Chrome profile reports the actually loaded version and performs its own safe runtime reload. +- Proposed safety invariant: a host update and an ERP task claim use the same organization serialization boundary; no update starts while any mapped account is active or uncertain, and no old-version browser receives a new ERP execution while a higher activity release is required. +- Proposed rollout invariant: `0.5.167` is the one-time unpacked-extension bootstrap in the shared ProgramData directory. Every later release is monotonic, content-addressed and hash-verified, retains one rollback directory, retries only definite failures up to three attempts, and is accepted only after a target-version browser heartbeat. +- Evidence: migration/config/source/tests and synchronized `0.5.167` release artifact from this task; official Chrome Windows distribution constraints and Alibaba Cloud `RunCommand`/`DescribeInvocationResults` contracts. +- Human confirmation required: production RAM policy/resource scope, the exact ECS account mappings, the bootstrap completion checkpoint, and authorization to migrate/restart/publish or execute a real Cloud Assistant canary. diff --git a/LianSyn-platform/app.js b/LianSyn-platform/app.js index ae71f7e..617cc8b 100644 --- a/LianSyn-platform/app.js +++ b/LianSyn-platform/app.js @@ -29,6 +29,8 @@ let erpAutomationEnabled = true; let erpHostAccessReady = true; let erpSessionReady = true; let extensionCompatible = false; +let extensionUpdateBlocksExecution = true; +let extensionReloadRequestedVersion = ''; let latestAiStatus = null; let latestBridgeStatus = null; let activeStatusDetails = ''; @@ -49,6 +51,9 @@ let parserRoutingBusy = false; let accountList = []; let accountTaskTypes = []; let accountSettingsBusy = false; +let extensionReleaseBusy = false; +let extensionReleaseEnabled = false; +let extensionReleaseList = []; let accountAuthorizationTargetId = ''; let accountAuthorizationDraft = new Set(); let auditEvents = []; @@ -97,7 +102,7 @@ const persistedExtensionResultVersions = new Map(); let taskCreateInProgress = false; const AUTO_HANDOFF_RETRY_MS = 30_000; -const REQUIRED_EXTENSION_VERSION = '0.5.166'; +const REQUIRED_EXTENSION_VERSION = '0.5.167'; const MANUAL_HANDOFF_LABEL = '确认并提交到 ERP 插件'; const RETRY_HANDOFF_LABEL = '继续提交到 ERP 插件'; const RECONCILE_LABEL = '只读回查 ERP 现有结果'; @@ -951,6 +956,8 @@ function validateAccountCreationValues(values = {}) { const password = String(values.password || ''); const role = String(values.role || ''); const erpAccount = String(values.erpAccount || '').trim(); + const ecsRegion = String(values.ecsRegion || '').trim().toLowerCase(); + const ecsInstance = String(values.ecsInstance || '').trim(); if (!username || username.length > 160) { return { ok: false, field: 'accountUsername', message: '账号必须为 1—160 个字符。' }; } @@ -963,6 +970,15 @@ function validateAccountCreationValues(values = {}) { if (role !== 'admin' && (!erpAccount || erpAccount.length > 200)) { return { ok: false, field: 'accountErpAccount', message: '普通用户或组长必须填写 1—200 个字符的 ERP 账号。' }; } + if (role !== 'admin' && Boolean(ecsRegion) !== Boolean(ecsInstance)) { + return { ok: false, field: ecsRegion ? 'accountEcsInstance' : 'accountEcsRegion', message: 'ECS 地域与实例必须同时填写或同时留空。' }; + } + if (ecsRegion && !/^[a-z0-9][a-z0-9-]{0,63}$/.test(ecsRegion)) { + return { ok: false, field: 'accountEcsRegion', message: 'ECS 地域 ID 格式无效。' }; + } + if (ecsInstance && !/^i-[A-Za-z0-9]{6,64}$/.test(ecsInstance)) { + return { ok: false, field: 'accountEcsInstance', message: 'ECS 实例 ID 格式无效。' }; + } return { ok: true, body: { @@ -970,6 +986,8 @@ function validateAccountCreationValues(values = {}) { password, role, erp_account: role === 'admin' ? '' : erpAccount, + extension_ecs_region_id: role === 'admin' ? '' : ecsRegion, + extension_ecs_instance_id: role === 'admin' ? '' : ecsInstance, business_route_ids: [] } }; @@ -1005,7 +1023,7 @@ function showAccountCreationValidationError(validation) { } function clearAccountCreationValidationErrors() { - for (const fieldId of ['accountUsername', 'accountPassword', 'accountRole', 'accountErpAccount']) { + for (const fieldId of ['accountUsername', 'accountPassword', 'accountRole', 'accountErpAccount', 'accountEcsRegion', 'accountEcsInstance']) { document.getElementById(fieldId)?.removeAttribute('aria-invalid'); } } @@ -1032,6 +1050,15 @@ function renderAccounts() { main.append(el('p', account.erp_account ? 'muted' : 'channel-error', account.role === 'admin' ? 'ERP 执行身份:不绑定' : `ERP 执行身份:${account.erp_account || '未配置(禁止执行)'}`)); + main.append(el( + 'p', + account.extension_ecs_instance_id ? 'muted' : 'channel-error', + account.role === 'admin' + ? '插件云主机:不绑定' + : account.extension_ecs_instance_id + ? `插件云主机:${account.extension_ecs_region_id} / ${account.extension_ecs_instance_id}` + : '插件云主机:未配置(不自动更新)' + )); const authorizedCount = Array.isArray(account.authorized_business_route_ids) ? account.authorized_business_route_ids.length : 0; @@ -1075,7 +1102,12 @@ function renderAccounts() { erpAccount.dataset.accountAction = 'erp-account'; erpAccount.dataset.accountId = account.id; erpAccount.disabled = accountSettingsBusy || account.role === 'admin'; - actions.append(role, authorizations, erpAccount, toggle, reset, revoke); + const extensionHost = el('button', 'secondary-button', '云主机'); + extensionHost.type = 'button'; + extensionHost.dataset.accountAction = 'extension-host'; + extensionHost.dataset.accountId = account.id; + extensionHost.disabled = accountSettingsBusy || account.role === 'admin'; + actions.append(role, authorizations, erpAccount, extensionHost, toggle, reset, revoke); row.append(main, actions); container.append(row); } @@ -1174,6 +1206,67 @@ async function syncAccounts() { accountTaskTypes = Array.isArray(result.task_types) ? result.task_types : []; renderAccounts(); renderChannelOwnerOptions(); + await syncExtensionReleases().catch((error) => { + const message = $('#extensionReleaseMessage'); + if (message) message.textContent = error.message || String(error); + }); +} + +function renderExtensionReleases() { + const summary = $('#extensionReleaseSummary'); + const button = $('#extensionReleasePublish'); + const file = $('#extensionReleaseFile'); + if (!summary || !button || !file) return; + const active = extensionReleaseList.find((release) => release.is_active === true); + summary.textContent = !extensionReleaseEnabled + ? '服务端自动更新尚未启用;启用配置后才能发布。' + : active + ? `当前活动版本 ${active.version} · ${Math.round(Number(active.byte_size || 0) / 1024)} KB · ${new Date(active.created_at).toLocaleString('zh-CN')}` + : '自动更新已启用,尚未发布任何插件包。'; + button.disabled = extensionReleaseBusy || !extensionReleaseEnabled || !file.files?.length; + file.disabled = extensionReleaseBusy || !extensionReleaseEnabled; +} + +async function syncExtensionReleases() { + if (!isAdministrator() || !IS_ACCOUNTS_PAGE) return; + const result = await apiRequest('/api/extension-updates/releases'); + extensionReleaseEnabled = result.enabled === true; + extensionReleaseList = Array.isArray(result.releases) ? result.releases : []; + renderExtensionReleases(); +} + +async function publishExtensionRelease() { + if (!isAdministrator() || extensionReleaseBusy || !extensionReleaseEnabled) return; + const input = $('#extensionReleaseFile'); + const file = input?.files?.[0]; + if (!file) return; + if (!/\.zip$/i.test(String(file.name || '')) || !file.size || file.size > 50_000_000) { + throw new Error('请选择不超过 50 MB 的版本化插件 ZIP。'); + } + extensionReleaseBusy = true; + renderExtensionReleases(); + const message = $('#extensionReleaseMessage'); + if (message) message.textContent = '正在校验并发布到 OSS…'; + try { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', () => resolve(String(reader.result || '')), { once: true }); + reader.addEventListener('error', () => reject(new Error('无法读取插件 ZIP。')), { once: true }); + reader.readAsDataURL(file); + }); + const packageBase64 = String(dataUrl).slice(String(dataUrl).indexOf(',') + 1); + const result = await apiRequest('/api/extension-updates/releases', { + method: 'POST', + timeoutMs: 120_000, + body: { package_base64: packageBase64 } + }); + if (message) message.textContent = `插件 ${result.release?.version || ''} 已发布;各云主机会在完全空闲后更新。`; + if (input) input.value = ''; + await syncExtensionReleases(); + } finally { + extensionReleaseBusy = false; + renderExtensionReleases(); + } } async function createAccountFromForm() { @@ -1182,7 +1275,9 @@ async function createAccountFromForm() { username: $('#accountUsername').value, password: $('#accountPassword').value, role: $('#accountRole').value, - erpAccount: $('#accountErpAccount').value + erpAccount: $('#accountErpAccount').value, + ecsRegion: $('#accountEcsRegion').value, + ecsInstance: $('#accountEcsInstance').value }); if (!validation.ok) { showAccountCreationValidationError(validation); @@ -1202,6 +1297,8 @@ async function createAccountFromForm() { $('#accountUsername').value = ''; $('#accountPassword').value = ''; $('#accountErpAccount').value = ''; + $('#accountEcsRegion').value = ''; + $('#accountEcsInstance').value = ''; if (message) { message.textContent = result.account?.role === 'admin' ? '管理员账号已创建;该角色固定拥有全部任务权限,初始密码不会再次显示。' @@ -1221,6 +1318,36 @@ async function updateManagedAccountErpIdentity(accountId) { await updateManagedAccount(accountId, { erp_account: value.trim().slice(0, 200) }); } +async function updateManagedAccountExtensionHost(accountId) { + const account = accountList.find((item) => item.id === accountId); + if (!account || account.role === 'admin') return; + const region = window.prompt( + '请输入 ECS 地域 ID(例如 cn-hangzhou)。留空会同时清除该账号的云主机绑定:', + account.extension_ecs_region_id || '' + ); + if (region === null) return; + if (!region.trim()) { + await updateManagedAccount(accountId, { + extension_ecs_region_id: null, + extension_ecs_instance_id: null + }); + return; + } + const instance = window.prompt( + '请输入 ECS 实例 ID(例如 i-bp...)。同一台 Windows Server 上的账号填写相同实例:', + account.extension_ecs_instance_id || '' + ); + if (instance === null || !instance.trim()) return; + if ( + region.trim().toLowerCase() === account.extension_ecs_region_id + && instance.trim() === account.extension_ecs_instance_id + ) return; + await updateManagedAccount(accountId, { + extension_ecs_region_id: region.trim().toLowerCase().slice(0, 64), + extension_ecs_instance_id: instance.trim().slice(0, 80) + }); +} + async function updateManagedAccount(accountId, patch) { if (!isAdministrator() || accountSettingsBusy) return; accountSettingsBusy = true; @@ -2587,7 +2714,7 @@ function isAutomaticTask(task) { } async function autoDispatchReadyTasks({ force = false } = {}) { - if (!authUser || autoHandoffInProgress) return; + if (!authUser || autoHandoffInProgress || extensionUpdateBlocksExecution) return; autoHandoffInProgress = true; try { const result = await apiRequest('/api/tasks?status=confirmed&limit=200&include_total=false&executable_by=me'); @@ -4303,7 +4430,9 @@ function renderStatusDetails() { const status = latestBridgeStatus; const recoveryStatus = String(status?.erpLinkRecovery?.status || status?.erpSession?.erp_keepalive?.recovery_status || ''); const recoveryPending = ['scheduled', 'already_running', 'running'].includes(recoveryStatus); - const hasWarning = status?.connected && (!status?.compatible || !status?.automationEnabled || !status?.erpHostAccessReady || status?.erpSessionReady === false || recoveryPending); + const updateStatus = String(status?.extensionUpdate?.status || ''); + const updateWarning = Boolean(updateStatus && !['current', 'disabled', 'no_release'].includes(updateStatus)); + const hasWarning = status?.connected && (!status?.compatible || !status?.automationEnabled || !status?.erpHostAccessReady || status?.erpSessionReady === false || recoveryPending || updateWarning); title.textContent = '插件状态详情'; refresh.textContent = '重新检测插件'; if (!status) { @@ -4324,6 +4453,8 @@ function renderStatusDetails() { statusDetailRow('插件版本', status?.version || '未知'), statusDetailRow('最低版本', REQUIRED_EXTENSION_VERSION), statusDetailRow('版本兼容', statusBoolean(status?.compatible, '兼容', '需要更新')), + statusDetailRow('自动更新', status?.extensionUpdate?.message || '等待服务端检测'), + statusDetailRow('目标版本', status?.extensionUpdate?.target_version || '尚未发布'), statusDetailRow('ERP 自动化', statusBoolean(status?.automationEnabled, '已开启', '已关闭')), statusDetailRow('ERP 页面权限', statusBoolean(status?.erpHostAccessReady, '可访问', '需在扩展设置中允许')), statusDetailRow('ERP 会话', statusBoolean(status?.erpSessionReady, '正常', '异常或待登录')), @@ -4384,8 +4515,6 @@ async function pingAi() { } function applyBridgePayload(payload = {}) { - const wasBridgeConnected = bridgeConnected; - const previousBridgeInstalledAt = String(latestBridgeStatus?.installedAt || ''); bridgeConnected = Boolean(payload.ok); if (!bridgeConnected) { erpAutomationEnabled = false; @@ -4396,6 +4525,7 @@ function applyBridgePayload(payload = {}) { connected: false, compatible: false, automationEnabled: false, + extensionUpdate: null, version: payload.version || '', installedAt: payload.bridge_installed_at || '', error: payload.message || payload.error || '插件桥接未返回成功状态。', @@ -4445,6 +4575,7 @@ function applyBridgePayload(payload = {}) { status: recoveryStatus || 'not_started', pending: erpLinkRecoveryPending }, + extensionUpdate: latestBridgeStatus?.extensionUpdate || null, version: payload.version || '', installedAt: bridgeInstalledAt, error: !extensionCompatible @@ -4468,12 +4599,40 @@ function applyBridgePayload(payload = {}) { setBridgeState('已连接', 'state-ok'); } renderStatusDetails(); - const bridgeReadyForDispatch = !wasBridgeConnected - || Boolean(bridgeInstalledAt && bridgeInstalledAt !== previousBridgeInstalledAt); - queueMicrotask(() => autoDispatchReadyTasks({ force: bridgeReadyForDispatch }).catch(() => {})); return true; } +function extensionUpdateIsBlocking(update = {}) { + return !['current', 'disabled', 'no_release'].includes(String(update?.status || '')); +} + +async function applyPreparedExtensionUpdate(update = {}) { + const targetVersion = String(update.target_version || ''); + if (!targetVersion || extensionReloadRequestedVersion === targetVersion) return; + extensionReloadRequestedVersion = targetVersion; + try { + const result = await sendToExtension('APPLY_EXTENSION_UPDATE', { + target_version: targetVersion + }, 5_000); + if (result?.accepted !== true && result?.status !== 'already_current') { + throw new Error(result?.message || '插件未接受安全重载请求。'); + } + if (result?.status === 'already_current') { + window.location.reload(); + return; + } + window.setTimeout(() => window.location.reload(), 900); + } catch (error) { + extensionReloadRequestedVersion = ''; + latestBridgeStatus = { + ...latestBridgeStatus, + error: error.message || String(error) + }; + setBridgeState('更新待重载', 'state-warn'); + renderStatusDetails(); + } +} + function setTaskState(text) { const node = $('#taskState'); if (!node) return; @@ -4519,7 +4678,8 @@ window.addEventListener('message', (event) => { const message = event.data || {}; if (message.source !== EXTENSION_SOURCE) return; if (message.type === 'BRIDGE_READY') { - applyBridgePayload(message.payload || { ok: true }); + if (authUser) void pingBridge(); + else applyBridgePayload(message.payload || { ok: true }); return; } if (message.type === 'TASK_RESULT_CHANGED') { @@ -4844,7 +5004,7 @@ async function confirmAndSubmitToErpPlugin(task) { throw new Error('该任务分配给其他平台账号,当前账号只能查看,不能确认或执行。'); } const bridgeReady = await pingBridge(); - if (!bridgeReady || !extensionCompatible || !erpAutomationEnabled || !erpHostAccessReady || !erpSessionReady || latestBridgeStatus?.erpLinkRecovery?.pending === true) { + if (!bridgeReady || !extensionCompatible || extensionUpdateBlocksExecution || !erpAutomationEnabled || !erpHostAccessReady || !erpSessionReady || latestBridgeStatus?.erpLinkRecovery?.pending === true) { throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION}、ERP 页面/会话不可用或 ERP 操作开关未开启;任务尚未确认,也未提交到插件。`); } const response = await apiRequest(`/api/tasks/${encodeURIComponent(task.task_id)}/confirm`, { method: 'POST', body: {} }); @@ -4888,9 +5048,36 @@ async function pingBridge() { extension_version: result.version || '', erp_account: authUser.erp_account || '', erp_account_matched: result.erp_session?.account_matched === true, - metadata: { bridge_installed_at: result.bridge_installed_at || '' } + extension_update_safe: result.extension_update?.safe === true, + metadata: { + bridge_installed_at: result.bridge_installed_at || '', + extension_update_reason: result.extension_update?.reason || '' + } } }); + const extensionUpdate = heartbeat.extension_update || { + status: 'disabled', + current_version: result.version || '', + target_version: '', + message: '插件自动更新未启用。' + }; + extensionUpdateBlocksExecution = extensionUpdateIsBlocking(extensionUpdate); + latestBridgeStatus = { + ...latestBridgeStatus, + extensionUpdate, + error: extensionUpdateBlocksExecution + ? extensionUpdate.message || '插件正在更新,已暂停新 ERP 任务。' + : latestBridgeStatus?.error || '' + }; + renderStatusDetails(); + if (extensionUpdateBlocksExecution) { + setBridgeState(extensionUpdate.status === 'reload_required' ? '更新待重载' : '插件更新中', 'state-warn'); + renderStatusDetails(); + } + if (extensionUpdate.action === 'reload') { + void applyPreparedExtensionUpdate(extensionUpdate); + return false; + } if (heartbeat.execution_ready !== true) { erpSessionReady = false; latestBridgeStatus = { @@ -4905,7 +5092,11 @@ async function pingBridge() { renderStatusDetails(); return false; } + if (!extensionUpdateBlocksExecution) { + queueMicrotask(() => autoDispatchReadyTasks({ force: true }).catch(() => {})); + } } catch (heartbeatError) { + extensionUpdateBlocksExecution = true; erpSessionReady = false; latestBridgeStatus = { ...latestBridgeStatus, @@ -4917,11 +5108,12 @@ async function pingBridge() { return false; } } - return bridgeConnected && erpSessionReady; + return bridgeConnected && erpSessionReady && !extensionUpdateBlocksExecution; } catch (error) { bridgeConnected = false; erpAutomationEnabled = false; extensionCompatible = false; + extensionUpdateBlocksExecution = true; latestBridgeStatus = { connected: false, compatible: false, @@ -5233,8 +5425,8 @@ async function pollTaskResult(taskId, { allowReconciliation = false } = {}) { async function reconcileTaskReceipt(task) { if (!canReconcileTask(task)) throw new Error('当前任务不在可只读回查的生命周期不确定状态。'); if (!taskAssignedToCurrentAccount(task)) throw new Error('该任务分配给其他平台账号,当前云电脑不能执行回查。'); - const bridgeReady = await pingBridge(); - if (!bridgeReady || !extensionCompatible || !erpHostAccessReady) throw new Error(`插件未连接、版本低于 ${REQUIRED_EXTENSION_VERSION} 或 ERP 页面权限不可用,未执行回查。`); + await pingBridge(); + if (!bridgeConnected || !erpHostAccessReady) throw new Error('插件未连接或 ERP 页面权限不可用,未执行回查。'); taskReconciliationStates.set(task.task_id, 'running'); renderTaskDetail(); try { @@ -5528,6 +5720,9 @@ document.addEventListener('DOMContentLoaded', async () => { taskAttachmentDrafts.clear(); accountList = []; accountTaskTypes = []; + extensionReleaseEnabled = false; + extensionReleaseList = []; + extensionReleaseBusy = false; accountAuthorizationTargetId = ''; accountAuthorizationDraft = new Set(); historySelectedTaskIds.clear(); @@ -5536,6 +5731,8 @@ document.addEventListener('DOMContentLoaded', async () => { historyBatchMessageKind = ''; currentTaskId = ''; browserConnectionId = ''; + extensionUpdateBlocksExecution = true; + extensionReloadRequestedVersion = ''; sessionStorage.removeItem('liansyn_platform_current_task_id'); $('#rosterAttachment').value = ''; $('#rosterAttachmentStatus').textContent = ''; @@ -5627,7 +5824,11 @@ document.addEventListener('DOMContentLoaded', async () => { const account = accountList.find((item) => item.id === control.dataset.accountId); if (!account) return; const patch = { role: control.value }; - if (control.value === 'admin') patch.erp_account = null; + if (control.value === 'admin') { + patch.erp_account = null; + patch.extension_ecs_region_id = null; + patch.extension_ecs_instance_id = null; + } if (control.value !== 'admin' && !account.erp_account) { const erpAccount = window.prompt('该员工角色必须绑定 ERP 账号,请输入 ERP 页面显示的登录账号:', ''); if (!erpAccount?.trim()) { @@ -5654,6 +5855,7 @@ document.addEventListener('DOMContentLoaded', async () => { return; } if (action === 'erp-account') operation = updateManagedAccountErpIdentity(account.id); + else if (action === 'extension-host') operation = updateManagedAccountExtensionHost(account.id); else if (action === 'toggle') operation = updateManagedAccount(account.id, { is_active: !account.is_active }); else if (action === 'reset-password') operation = resetManagedAccountPassword(account.id); else if (action === 'revoke-sessions') operation = revokeManagedAccountSessions(account.id); @@ -5818,10 +6020,25 @@ document.addEventListener('DOMContentLoaded', async () => { }); $('#accountRole')?.addEventListener('change', (event) => { const erpInput = $('#accountErpAccount'); + const ecsRegionInput = $('#accountEcsRegion'); + const ecsInstanceInput = $('#accountEcsInstance'); const admin = event.target.value === 'admin'; erpInput.required = !admin; erpInput.disabled = admin; - if (admin) erpInput.value = ''; + ecsRegionInput.disabled = admin; + ecsInstanceInput.disabled = admin; + if (admin) { + erpInput.value = ''; + ecsRegionInput.value = ''; + ecsInstanceInput.value = ''; + } + }); + $('#extensionReleaseFile')?.addEventListener('change', renderExtensionReleases); + $('#extensionReleasePublish')?.addEventListener('click', () => { + void publishExtensionRelease().catch((error) => { + const message = $('#extensionReleaseMessage'); + if (message) message.textContent = error.message || String(error); + }); }); $('#aiState').addEventListener('click', () => { toggleStatusDetails('ai').catch(() => {}); diff --git a/LianSyn-platform/index.html b/LianSyn-platform/index.html index a1297f0..b5781f3 100644 --- a/LianSyn-platform/index.html +++ b/LianSyn-platform/index.html @@ -99,8 +99,21 @@ + + +
+
+

插件版本发布

+

正在读取服务端发布状态…

+
+
+ + +
+

+