fix(密钥配置): 保存沿用刚检测过的输入值结果,消除「三项检测通过却提示未检测」死循环

- 服务端:对「输入值(未保存)」的检测结果按 uid+模块+值指纹暂存 Redis(TTL 30min,
  Redis 异常降级为需重新检测,不阻断保存);保存同一个值时落库该结果
  (passed/failed/error 一并沿用),改过值或从未检测则维持未检测
- 前端:保存后清理本地「输入值(未保存)」绿字,展示统一走服务端快照,避免展示与门禁矛盾;
  门禁提示改列「模块名(掩码):未检测 / 检测失败:原因 / 未配置」,同名掩码也能分辨模块
- 测试:UserApiSecretServiceTest 补沿用 / 值不一致 / Redis 降级用例;
  新增前后端一致性守卫测试(保存后必须清检测结果、提示必须带模块名)
This commit is contained in:
2026-09-14 13:49:25 +08:00
parent 5ea52e5291
commit 8cab9d4bad
6 changed files with 321 additions and 16 deletions
@@ -14,7 +14,7 @@
<div class="setup-card-title">完成密钥配置后即可使用</div>
<div class="setup-card-desc">
密钥保存在服务端并绑定当前账号换设备登录后自动同步
请填写以下密钥保存检测通过即可进入工具台
请填写以下密钥检测确认可用后保存全部检测通过即可进入工具台
</div>
<ApiSecretSettingsPanel ref="panelRef" />
@@ -48,7 +48,7 @@ const route = useRoute()
const panelRef = ref<InstanceType<typeof ApiSecretSettingsPanel> | null>(null)
const submitting = ref(false)
const username = ref('')
const hint = ref('填写密钥并保存后,请点击对应模块的「检测」按钮确认可用,全部检测通过后即可进入工具台。')
const hint = ref('填写密钥后请先点「检测」确认可用再保存(检测通过的输入值保存时会沿用同一结果),全部检测通过后即可进入工具台。')
onMounted(() => {
try {
@@ -58,7 +58,10 @@ onMounted(() => {
}
})
/** 提交:保存后只校验检测状态;检测一律由用户手动点击「检测」触发(2026-09-13 起不再自动补检)。 */
/**
* 提交:保存后只校验检测状态;检测一律由用户手动点「检测」触发(服务端会沿用刚检测过的同一个输入值,
* 因此「检测 → 保存并进入」一次即可通过;改过值或从未检测的模块仍需重新检测)。
*/
async function submit() {
if (submitting.value) return
const panel = panelRef.value
@@ -71,9 +74,24 @@ async function submit() {
const state = await loadApiSecrets({ force: true })
if (state === 'incomplete') {
const failed = listApiSecretModules()
.map((module) => getStoredApiSecretSnapshot(module.moduleKey as ApiSecretModuleKey))
.filter((snapshot) => !snapshot.exists || (snapshot.checkStatus !== 'passed' && snapshot.checkStatus !== 'error'))
hint.value = `以下密钥尚未通过检测:${failed.map((item) => item.masked || '未配置').join('、')},请点击上方的「检测」按钮逐项确认后重试。`
.map((module) => ({
module,
snapshot: getStoredApiSecretSnapshot(module.moduleKey as ApiSecretModuleKey),
}))
.filter(({ snapshot }) => !snapshot.exists || (snapshot.checkStatus !== 'passed' && snapshot.checkStatus !== 'error'))
// 逐项列出「模块名(掩码)+ 未通过原因」:同一密钥填进多个模块时也能分清是哪个模块
const detail = failed.map(({ module, snapshot }) => {
const masked = snapshot.masked || '未配置'
if (!snapshot.exists) {
return `${module.moduleLabel}${masked})尚未配置`
}
if (snapshot.checkStatus === 'failed') {
const reason = snapshot.checkMessage ? `${snapshot.checkMessage.slice(0, 40)}` : ''
return `${module.moduleLabel}${masked})检测失败${reason}`
}
return `${module.moduleLabel}${masked})尚未检测`
})
hint.value = `以下密钥尚未通过检测:${detail.join('')}。请点击上方对应模块的「检测」按钮确认后重试。`
ElMessage.warning('密钥尚未全部检测通过,请点击「检测」按钮逐项确认')
return
}
@@ -465,8 +465,8 @@ async function loadBalance() {
/**
* 保存:逐模块保存非空输入(空输入保留服务端原值),代理仅在改动时保存。
* 保存后不再自动检测(2026-09-13 起检测一律由用户手动点击触发)
* 保存会把检测状态重置为未检测,需用户点「检测」确认可用性。
* 检测一律由用户手动点击触发;保存时服务端会沿用「刚检测过的同一个输入值」的结果
* 其余情况(改过值 / 从未检测)保持未检测,需用户点「检测」确认可用性。
*/
async function saveAll(options: { requireAll?: boolean } = {}): Promise<boolean> {
if (busy.value || proxyLoading.value) return false
@@ -486,8 +486,14 @@ async function saveAll(options: { requireAll?: boolean } = {}): Promise<boolean>
try {
for (const module of pendingModules) {
const moduleKey = module.moduleKey as ApiSecretModuleKey
await saveApiSecret(moduleKey, ensureModuleState(module.moduleKey).input.trim())
ensureModuleState(module.moduleKey).input = ''
const state = ensureModuleState(module.moduleKey)
await saveApiSecret(moduleKey, state.input.trim())
state.input = ''
// 输入值已保存:本地那次「检测输入值」的结果不再代表当前配置,清掉后展示统一走服务端快照
// (保存前检测过的同一个值,服务端会沿用那次结果,快照里直接是「检测通过」)
state.result = null
state.lastCheckSource = ''
state.error = ''
}
secretsSaved = true
refreshSnapshots()
@@ -499,6 +505,9 @@ async function saveAll(options: { requireAll?: boolean } = {}): Promise<boolean>
await api.save_config(userProxyPatch(nextProxyUrl, proxyMode.value) as DesktopConfigUpdate)
proxyUrl.value = nextProxyUrl
proxyDirty.value = false
// 同上:地址已保存,本地「未保存」的检测文案不再适用
proxyCheckResult.value = null
proxyCheckError.value = ''
await syncProxyToServer(nextProxyUrl)
}
@@ -0,0 +1,59 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { resolve, dirname } from 'node:path'
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const read = (rel: string) => readFileSync(resolve(repoRoot, rel), 'utf-8')
/** 截取函数体:先按括号配平跳过参数列表,再按花括号配平取函数体(模板字面量里的 ${} 也是配对的,不影响计数)。 */
function functionBody(source: string, signature: string): string {
const start = source.indexOf(signature)
assert.ok(start >= 0, `未找到 ${signature}`)
const openParen = source.indexOf('(', start)
let parenDepth = 0
let cursor = openParen
for (; cursor < source.length; cursor += 1) {
if (source[cursor] === '(') parenDepth += 1
else if (source[cursor] === ')') {
parenDepth -= 1
if (parenDepth === 0) break
}
}
const openBrace = source.indexOf('{', cursor)
let depth = 0
for (let i = openBrace; i < source.length; i += 1) {
if (source[i] === '{') depth += 1
else if (source[i] === '}') {
depth -= 1
if (depth === 0) return source.slice(openBrace, i + 1)
}
}
throw new Error('函数体未闭合')
}
/**
* 密钥卡片保存后必须清掉本地「检测输入值(未保存)」的结果:
* 保存后输入框已清空,那份绿字不再代表当前配置,继续展示就会出现
* 「三项都显示检测通过、页面却提示尚未通过检测」的自相矛盾状态(2026-09-14 修复)。
*/
test('密钥面板保存后清理过期检测结果', () => {
const body = functionBody(read('src/shared/components/ApiSecretSettingsPanel.vue'), 'async function saveAll(')
const saveIndex = body.indexOf('await saveApiSecret(')
assert.ok(saveIndex >= 0, 'saveAll 应逐个保存模块输入值')
const afterSave = body.slice(saveIndex)
assert.match(afterSave, /state\.input = ''/, '保存后应清空输入框')
assert.match(afterSave, /state\.result = null/, '保存后应清掉本地检测结果')
assert.match(afterSave, /state\.lastCheckSource = ''/, '保存后应重置检测来源标记')
})
/**
* 门禁拦截提示必须带模块名:同一个密钥填进多个模块时,
* 只列脱敏值会出现「sk-P****9BgQ、sk-P****9BgQ」这种分不清是哪个模块的提示。
*/
test('密钥门禁提示按模块名列出未通过项', () => {
const body = functionBody(read('src/pages/setup/DesktopSecretSetupPage.vue'), 'async function submit(')
assert.match(body, /module\.moduleLabel/, '未通过提示应包含模块名')
assert.match(body, /snapshot\.masked/, '未通过提示应包含脱敏值')
})