diff --git a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js index 13bc426..d2eede8 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js +++ b/frontend/src/views/components/ConfigSettings/ConfigForm/ConfigForm.js @@ -181,12 +181,14 @@ const sslGroups = [ field('tls.certificateRef', '证书文件', 'text', { wide: true, show: externalManagedCertificate, - placeholder: 'config/ssl/example.com/fullchain.pem' + placeholder: 'config/ssl/example.com/fullchain.pem', + hint: '填写后端服务器上的 PEM 证书路径。自定义模式支持绝对路径或相对运行根目录的路径,保存后显示导入后的受管路径;Web 服务器管理模式填写已有受管证书的相对路径。' }), field('tls.privateKeyRef', '私钥文件', 'text', { wide: true, show: externalManagedCertificate, - placeholder: 'config/ssl/example.com/privkey.pem' + placeholder: 'config/ssl/example.com/privkey.pem', + hint: '填写与证书匹配的 PEM 私钥文件路径,不是私钥内容。文件必须可被后端运行用户读取;自定义模式保存后显示受管副本的路径。' }) ]) ] diff --git a/frontend/src/views/components/ConfigSettings/ConfigSettings.js b/frontend/src/views/components/ConfigSettings/ConfigSettings.js index bcb7200..a42bb34 100644 --- a/frontend/src/views/components/ConfigSettings/ConfigSettings.js +++ b/frontend/src/views/components/ConfigSettings/ConfigSettings.js @@ -275,7 +275,7 @@ export function usePage(pageKey = 'base') { } } - const loadSection = async () => { + const loadSection = async ({ minimumGeneration = 0 } = {}) => { const requestId = ++sectionRequestId const section = apiSection() sectionLoading.value = true @@ -290,19 +290,25 @@ export function usePage(pageKey = 'base') { savedInput = null try { const response = await http.get(`/api/v1/config/${section}`) - if (requestId !== sectionRequestId) return + if (requestId !== sectionRequestId || disposed) return false notifyResponseError(response, '配置分区读取失败') const publicConfig = response.data || {} + const generation = Number(section === 'base' ? publicConfig.generation : publicConfig.meta?.generation) + if (minimumGeneration && (!Number.isSafeInteger(generation) || generation < minimumGeneration)) { + throw new Error('读取到的配置版本落后于本次保存,请刷新状态核对') + } editorText.value = JSON.stringify(publicConfig, null, 2) loadedText.value = editorText.value if (section === 'base') { const database = publicConfig.database || {} Object.assign(databaseConfig, { type: database.type || '', name: database.name || '', localRef: database.localRef || '' }) } + return true } catch (error) { - if (requestId !== sectionRequestId) return + if (requestId !== sectionRequestId || disposed) return false sectionError.value = error.message || '配置分区读取失败' editorText.value = '' + return false } finally { if (requestId === sectionRequestId) sectionLoading.value = false } @@ -597,6 +603,8 @@ export function usePage(pageKey = 'base') { return false } working.value = 'apply' + const generation = savedGeneration.value + let applied = false try { const response = await requestWithRecentAuthentication(() => http.post( `/api/v1/config/${apiSection()}/apply`, @@ -606,13 +614,30 @@ export function usePage(pageKey = 'base') { } )) notifyResponseError(response, '配置保存失败') - savedGeneration.value = 0 - await Promise.all([loadStatus(), loadSection()]) + applied = true + // 服务端已提交的候选不能再次应用;回读失败时只允许刷新,不误报保存失败或展示旧配置。 + pendingCandidate.value = null + invalidateCandidate() + await loadStatus() + if (disposed) return false + if (!Number.isSafeInteger(Number(status.activeGeneration)) || Number(status.activeGeneration) < generation) { + throw new Error('配置状态尚未更新') + } + if (!await loadSection({ minimumGeneration: generation })) throw new Error('最新配置读取失败') await loadPending() + if (disposed) return false ElMessage.success(successMessage) return true } catch (error) { + if (disposed) return false if (isRecentAuthenticationCancelled(error)) return false + if (applied) { + sectionError.value = '配置已应用,但最新配置读取失败,请刷新状态核对' + editorText.value = '' + loadedText.value = '' + ElMessage.warning(sectionError.value) + return false + } ElMessage.error(error.message || '配置保存失败') return false } finally { diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs index 7350350..2bb9e7e 100644 --- a/test/frontend/config-settings.test.mjs +++ b/test/frontend/config-settings.test.mjs @@ -37,9 +37,10 @@ const createPage = (...args) => createPageWithKey(...args, 'base', () => {}) function fixture(get, pageKey = 'base', options = {}) { const messages = [] + const successes = [] let dispose = () => {} const page = createPageWithKey(computed, () => {}, reactive, ref, - { error: message => messages.push(message), warning: message => messages.push(message), success: () => {} }, + { error: message => messages.push(message), warning: message => messages.push(message), success: message => successes.push(message) }, { confirm: options.confirm || (async () => {}), prompt: options.prompt || (async () => { throw 'cancel' }) @@ -48,7 +49,7 @@ function fixture(get, pageKey = 'base', options = {}) { ...options.http }, () => ({ hasCapability: options.hasCapability || (() => true) }), pageKey, callback => { dispose = callback }) - return { page, messages, dispose: () => dispose() } + return { page, messages, successes, dispose: () => dispose() } } function deferred() { @@ -454,21 +455,22 @@ test('配置编辑页将验证、候选保存和应用精简为单一保存操 test('保存并应用一次点击按候选协议连续完成保存和应用', async () => { const requests = [] let pending = null - const active = { + let active = { + meta: { generation: 4 }, listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' }, imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: { maxMessageBytes: 10 << 20, spamFilterLevel: 2 } } - const { page } = fixture(async path => ({ + const { page, successes, messages } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 4 } : active }), 'mail', { http: { get: async path => ({ errorNo: 0, - data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 4 } : active + data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : active }), put: async (path, body) => { requests.push(path) @@ -479,17 +481,21 @@ test('保存并应用一次点击按候选协议连续完成保存和应用', as requests.push(path) assert.equal(body.generation, 5) assert.equal(body.candidateId, 'one-click-5') + active = { ...pending.payload, meta: { generation: 5 } } pending = null return { errorNo: 0, data: { generation: 5 } } } } }) await page.loadAll() - page.updateField(['policy', 'maxMessageBytes'], 12 << 20) + page.updateField(['listener', 'port'], 2525) await page.saveAndApply() assert.deepEqual(requests, ['/api/v1/config/mail', '/api/v1/config/mail/apply']) assert.equal(page.pendingCandidate.value, null) assert.equal(page.savedGeneration.value, 0) + assert.deepEqual(successes, ['配置已保存']) + assert.deepEqual(messages, []) + assert.equal(page.formConfig.value.listener.port, 2525) }) test('最近认证过期时在当前页确认密码并自动重试保存和应用', async () => { @@ -497,18 +503,19 @@ test('最近认证过期时在当前页确认密码并自动重试保存和应 let pending = null let saveAttempts = 0 let prompts = 0 - const active = { + let active = { + meta: { generation: 1 }, listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, relay: { relayHost: '', relayPort: 0, security: '', from: '', fromDomain: '' }, imap: { tlsPort: 143 }, pop3: { port: 110, tlsPort: 0 }, policy: {} } const recentAuthError = Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' }) - const { page } = fixture(async () => ({ errorNo: 0, data: active }), 'mail', { + const { page, successes, messages } = fixture(async () => ({ errorNo: 0, data: active }), 'mail', { prompt: async () => { prompts++; return { value: 'CurrentPass12' } }, http: { get: async path => ({ errorNo: 0, - data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 1 } : active + data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : active }), put: async (path, body) => { requests.push(path) @@ -523,6 +530,7 @@ test('最近认证过期时在当前页确认密码并自动重试保存和应 assert.deepEqual(body, { password: 'CurrentPass12' }) return { errorNo: 0, data: { reauthenticated: true } } } + active = { ...pending.payload, meta: { generation: 2 } } pending = null return { errorNo: 0, data: { generation: 2 } } } @@ -532,6 +540,8 @@ test('最近认证过期时在当前页确认密码并自动重试保存和应 page.updateField(['listener', 'port'], 2525) await page.saveAndApply() assert.equal(prompts, 1) + assert.deepEqual(successes, ['配置已保存']) + assert.deepEqual(messages, []) assert.deepEqual(requests, [ '/api/v1/config/mail', '/api/v1/security/reauth', @@ -657,7 +667,8 @@ test('只读账号不得发送配置写入请求或读取无权限维护数据', test('邮件页保持验证、保存、应用端点和代次协议,中继密码只作为 secretChanges 发送', async () => { const requests = [] let pending = null - const active = { + let active = { + meta: { generation: 8 }, listener: { port: 25, starttlsPort: 587, tlsPort: 0, security: 'starttls' }, relay: { relayHost: 'smtp.example.com', relayPort: 587, security: 'starttls', @@ -667,8 +678,15 @@ test('邮件页保持验证、保存、应用端点和代次协议,中继密 } const { page } = fixture(async path => ({ errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: 8 } : active }), 'mail', { http: { - get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 8 } : active }), - post: async (path, body) => { requests.push({ path, body }); if (path.endsWith('/apply')) pending = null; return { errorNo: 0, data: { valid: true } } }, + get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: active.meta.generation } : active }), + post: async (path, body) => { + requests.push({ path, body }) + if (path.endsWith('/apply')) { + active = { ...pending.payload, meta: { generation: 9 } } + pending = null + } + return { errorNo: 0, data: { valid: true } } + }, put: async (path, body) => { requests.push({ path, body }) pending = { generation: 9, candidateId: 'candidate-9', section: 'mail', sameSession: true, payload: body.payload } @@ -682,7 +700,7 @@ test('邮件页保持验证、保存、应用端点和代次协议,中继密 await page.validateConfig() await page.saveConfig() assert.equal(page.savedGeneration.value, 9) - await page.applyConfig() + assert.equal(await page.applyConfig(), true) assert.deepEqual(requests.map(item => item.path), ['/api/v1/config/mail/validate', '/api/v1/config/mail', '/api/v1/config/mail/apply']) assert.equal(requests[0].body.expectedGeneration, 8) assert.equal(requests[0].body.payload.relay.password, undefined) @@ -698,6 +716,7 @@ test('邮件页保持验证、保存、应用端点和代次协议,中继密 test('自定义证书导入后使用规范化的候选引用继续应用,不误判表单已变化', async () => { const requests = [] let pending = null + let generation = 8 let active = { tls: { management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl', @@ -705,15 +724,16 @@ test('自定义证书导入后使用规范化的候选引用继续应用,不 } } const normalized = { + meta: { generation: 9 }, tls: { ...active.tls, certificateRef: 'config/ssl/managed/upload/version-0123456789abcdef0123456789abcdef/public.crt', privateKeyRef: 'config/ssl/managed/upload/version-0123456789abcdef0123456789abcdef/private.key' } } - const { page } = fixture(async () => ({ errorNo: 0, data: active }), 'ssl', { + const { page, successes, messages } = fixture(async () => ({ errorNo: 0, data: active }), 'ssl', { http: { - get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: 8 } : active }), + get: async path => ({ errorNo: 0, data: path.endsWith('/pending') ? pending : path.endsWith('/status') ? { activeGeneration: generation } : active }), put: async (path, body) => { requests.push({ path, body }) pending = { generation: 9, candidateId: 'ssl-candidate', section: 'ssl', sameSession: true, payload: normalized } @@ -722,6 +742,7 @@ test('自定义证书导入后使用规范化的候选引用继续应用,不 post: async (path, body) => { requests.push({ path, body }) active = normalized + generation = 9 pending = null return { errorNo: 0, data: { generation: 9 } } } @@ -733,8 +754,77 @@ test('自定义证书导入后使用规范化的候选引用继续应用,不 assert.deepEqual(requests[1].body, { generation: 9, candidateId: 'ssl-candidate' }) assert.equal(JSON.parse(page.editorText.value).tls.certificateRef, normalized.tls.certificateRef) assert.equal(page.savedGeneration.value, 0) + assert.deepEqual(successes, ['配置已保存']) + assert.deepEqual(messages, []) }) +for (const failure of ['section-network', 'section-response', 'status-network', 'pending-network', 'section-stale', 'status-stale']) { + test(`证书应用后的 ${failure} 不得误报完成,刷新后恢复真实配置`, async () => { + const original = { + meta: { generation: 8 }, + tls: { + management: 'custom', mode: 'managed_upload', defaultDirectory: 'config/ssl', customDirectory: 'config/ssl', + certificateRef: 'config/ssl/old/public.crt', privateKeyRef: 'config/ssl/old/private.key' + } + } + const normalized = { + meta: { generation: 9 }, + tls: { + ...original.tls, + certificateRef: 'config/ssl/managed/upload/version-0123456789abcdef0123456789abcdef/public.crt', + privateKeyRef: 'config/ssl/managed/upload/version-0123456789abcdef0123456789abcdef/private.key' + } + } + let applied = false + let fail = true + let pending = null + let writes = 0 + const { page, messages, successes } = fixture(() => {}, 'ssl', { http: { + get: async path => { + if (applied && fail) { + if (failure === 'section-network' && path.endsWith('/ssl') || + failure === 'status-network' && path.endsWith('/status') || + failure === 'pending-network' && path.endsWith('/pending')) throw new Error('模拟回读失败') + if (failure === 'section-response' && path.endsWith('/ssl')) return { errorNo: 500, errorMsg: '配置分区读取失败' } + } + const statusGeneration = applied && !(fail && failure === 'status-stale') ? 9 : 8 + const active = applied && !(fail && failure === 'section-stale') ? normalized : original + return { errorNo: 0, data: path.endsWith('/status') ? { activeGeneration: statusGeneration } : path.endsWith('/pending') ? pending : active } + }, + put: async () => { + writes++ + pending = { generation: 9, candidateId: 'ssl-readback', section: 'ssl', sameSession: true, payload: normalized } + return { errorNo: 0, data: pending } + }, + post: async () => { + writes++ + applied = true + pending = null + return { errorNo: 0, data: { generation: 9 } } + } + } }) + await page.loadAll() + page.updateField(['tls', 'certificateRef'], 'D:/certs/new/public.crt') + page.updateField(['tls', 'privateKeyRef'], 'D:/certs/new/private.key') + await page.saveAndApply() + assert.equal(applied, true) + assert.deepEqual(successes, []) + assert.match(messages.at(-1), /配置已应用.*刷新/) + assert.ok(page.sectionError.value) + assert.equal(page.pendingCandidate.value, null) + assert.equal(page.savedGeneration.value, 0) + assert.equal(page.editorText.value, '') + await page.saveAndApply() + assert.equal(writes, 2) + fail = false + await page.refreshPage() + assert.equal(page.sectionError.value, '') + assert.equal(page.formConfig.value.tls.certificateRef, normalized.tls.certificateRef) + assert.equal(page.status.activeGeneration, 9) + assert.equal(page.hasChanges.value, false) + }) +} + test('可视化编辑按权限和工作状态保护,编辑会清除过期确认及候选代次', async () => { const config = { listener: { port: 25, starttlsPort: 587, tlsPort: 465 },