feat: droid的apikey模式适配多种更新方式

This commit is contained in:
shaw
2025-10-11 22:15:38 +08:00
parent 6dcb8b9449
commit 53dee11a10
2 changed files with 253 additions and 41 deletions

View File

@@ -932,7 +932,26 @@ class DroidAccountService {
: '' : ''
) )
const newApiKeysInput = Array.isArray(updates.apiKeys) ? updates.apiKeys : [] const newApiKeysInput = Array.isArray(updates.apiKeys) ? updates.apiKeys : []
const removeApiKeysInput = Array.isArray(updates.removeApiKeys) ? updates.removeApiKeys : []
const wantsClearApiKeys = Boolean(updates.clearApiKeys) const wantsClearApiKeys = Boolean(updates.clearApiKeys)
const rawApiKeyMode =
typeof updates.apiKeyUpdateMode === 'string'
? updates.apiKeyUpdateMode.trim().toLowerCase()
: ''
let apiKeyUpdateMode = ['append', 'replace', 'delete'].includes(rawApiKeyMode)
? rawApiKeyMode
: ''
if (!apiKeyUpdateMode) {
if (wantsClearApiKeys) {
apiKeyUpdateMode = 'replace'
} else if (removeApiKeysInput.length > 0) {
apiKeyUpdateMode = 'delete'
} else {
apiKeyUpdateMode = 'append'
}
}
if (sanitizedUpdates.apiKeys !== undefined) { if (sanitizedUpdates.apiKeys !== undefined) {
delete sanitizedUpdates.apiKeys delete sanitizedUpdates.apiKeys
@@ -940,33 +959,94 @@ class DroidAccountService {
if (sanitizedUpdates.clearApiKeys !== undefined) { if (sanitizedUpdates.clearApiKeys !== undefined) {
delete sanitizedUpdates.clearApiKeys delete sanitizedUpdates.clearApiKeys
} }
if (sanitizedUpdates.apiKeyUpdateMode !== undefined) {
delete sanitizedUpdates.apiKeyUpdateMode
}
if (sanitizedUpdates.removeApiKeys !== undefined) {
delete sanitizedUpdates.removeApiKeys
}
if (wantsClearApiKeys || newApiKeysInput.length > 0) { let mergedApiKeys = existingApiKeyEntries
const mergedApiKeys = this._buildApiKeyEntries( let apiKeysUpdated = false
let addedCount = 0
let removedCount = 0
if (apiKeyUpdateMode === 'delete') {
const removalHashes = new Set()
for (const candidate of removeApiKeysInput) {
if (typeof candidate !== 'string') {
continue
}
const trimmed = candidate.trim()
if (!trimmed) {
continue
}
const hash = crypto.createHash('sha256').update(trimmed).digest('hex')
removalHashes.add(hash)
}
if (removalHashes.size > 0) {
mergedApiKeys = existingApiKeyEntries.filter(
(entry) => entry && entry.hash && !removalHashes.has(entry.hash)
)
removedCount = existingApiKeyEntries.length - mergedApiKeys.length
apiKeysUpdated = removedCount > 0
if (!apiKeysUpdated) {
logger.warn(
`⚠️ 删除模式未匹配任何 Droid API Key: ${accountId} (提供 ${removalHashes.size} 条)`
)
}
} else if (removeApiKeysInput.length > 0) {
logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`)
}
} else {
const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys
const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length
mergedApiKeys = this._buildApiKeyEntries(
newApiKeysInput, newApiKeysInput,
existingApiKeyEntries, existingApiKeyEntries,
wantsClearApiKeys clearExisting
) )
const baselineCount = wantsClearApiKeys ? 0 : existingApiKeyEntries.length addedCount = Math.max(mergedApiKeys.length - baselineCount, 0)
const addedCount = Math.max(mergedApiKeys.length - baselineCount, 0) apiKeysUpdated = clearExisting || addedCount > 0
}
if (apiKeysUpdated) {
sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : '' sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : ''
sanitizedUpdates.apiKeyCount = String(mergedApiKeys.length) sanitizedUpdates.apiKeyCount = String(mergedApiKeys.length)
if (apiKeyUpdateMode === 'delete') {
logger.info(
`🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}`
)
} else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) {
logger.info(
`🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}`
)
} else {
logger.info(
`🔑 追加模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}`
)
}
if (mergedApiKeys.length > 0) { if (mergedApiKeys.length > 0) {
sanitizedUpdates.authenticationMethod = 'api_key' sanitizedUpdates.authenticationMethod = 'api_key'
sanitizedUpdates.status = sanitizedUpdates.status || 'active' sanitizedUpdates.status = sanitizedUpdates.status || 'active'
logger.info( } else if (!sanitizedUpdates.accessToken && !account.accessToken) {
`🔑 Updated Droid API keys for ${accountId}: total ${mergedApiKeys.length} (added ${addedCount})` const shouldPreserveApiKeyMode =
) account.authenticationMethod &&
} else { account.authenticationMethod.toLowerCase().trim() === 'api_key' &&
logger.info(`🔑 Cleared all API keys for Droid account ${accountId}`) (apiKeyUpdateMode === 'replace' || apiKeyUpdateMode === 'delete')
// 如果完全移除 API Key可根据是否仍有 token 来确定认证方式
if (!sanitizedUpdates.accessToken && !account.accessToken) { sanitizedUpdates.authenticationMethod = shouldPreserveApiKeyMode
sanitizedUpdates.authenticationMethod = ? 'api_key'
account.authenticationMethod === 'api_key' ? '' : account.authenticationMethod : account.authenticationMethod === 'api_key'
} ? ''
: account.authenticationMethod
} }
} }

View File

@@ -2928,10 +2928,10 @@
</h5> </h5>
<p class="mb-1 text-sm text-purple-800 dark:text-purple-200"> <p class="mb-1 text-sm text-purple-800 dark:text-purple-200">
当前已保存 <strong>{{ existingApiKeyCount }}</strong> 条 API Key。您可以追加新的 当前已保存 <strong>{{ existingApiKeyCount }}</strong> 条 API Key。您可以追加新的
Key 或使用下方选项清空后重新填写 Key,或通过下方模式快速覆盖、删除指定 Key
</p> </p>
<p class="text-xs text-purple-700 dark:text-purple-300"> <p class="text-xs text-purple-700 dark:text-purple-300">
留空表示保留现有 Key 不变;填写内容后将覆盖或追加(视清空选项而定) 留空表示保留现有 Key 不变;根据所选模式决定是追加、覆盖还是删除输入的 Key
</p> </p>
</div> </div>
</div> </div>
@@ -2945,7 +2945,7 @@
v-model="form.apiKeysInput" v-model="form.apiKeysInput"
class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400" class="form-input w-full resize-none border-gray-300 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.apiKeys }" :class="{ 'border-red-500': errors.apiKeys }"
placeholder="留空表示不更新;每行一个 API Key" placeholder="根据模式填写;每行一个 API Key"
rows="6" rows="6"
/> />
<p v-if="errors.apiKeys" class="mt-1 text-xs text-red-500"> <p v-if="errors.apiKeys" class="mt-1 text-xs text-red-500">
@@ -2953,16 +2953,41 @@
</p> </p>
</div> </div>
<label <div class="space-y-2">
class="flex cursor-pointer items-center gap-2 rounded-md border border-purple-200 bg-white/80 px-3 py-2 text-sm text-purple-800 transition-colors hover:border-purple-300 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100" <div class="flex items-center justify-between">
<span class="text-sm font-semibold text-purple-800 dark:text-purple-100"
>API Key 更新模式</span
> >
<input <span class="text-xs text-purple-600 dark:text-purple-300">
v-model="form.clearExistingApiKeys" {{ currentApiKeyModeLabel }}
class="rounded border-purple-300 text-purple-600 focus:ring-purple-500 dark:border-purple-500 dark:bg-purple-900" </span>
type="checkbox" </div>
<div
class="relative grid h-11 grid-cols-3 overflow-hidden rounded-2xl border border-purple-200/80 bg-gradient-to-r from-purple-50/80 via-white to-purple-50/80 shadow-inner dark:border-purple-700/70 dark:from-purple-900/40 dark:via-purple-900/20 dark:to-purple-900/40"
>
<span
class="pointer-events-none absolute inset-y-0 rounded-2xl bg-gradient-to-r from-purple-500/90 via-purple-600 to-indigo-500/90 shadow-lg ring-1 ring-purple-100/80 transition-all duration-300 ease-out dark:from-purple-500/70 dark:via-purple-600/70 dark:to-indigo-500/70 dark:ring-purple-400/30"
:style="apiKeyModeSliderStyle"
/> />
<span>清空已有 API Key 后再应用上方的 Key 列表</span> <button
</label> v-for="option in apiKeyModeOptions"
:key="option.value"
class="relative z-10 flex items-center justify-center rounded-2xl px-2 text-xs font-semibold transition-all duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-500/60 dark:focus-visible:ring-purple-400/60"
:class="
form.apiKeyUpdateMode === option.value
? 'text-white drop-shadow-sm'
: 'text-purple-500/80 hover:text-purple-700 dark:text-purple-200/70 dark:hover:text-purple-100'
"
type="button"
@click="form.apiKeyUpdateMode = option.value"
>
{{ option.label }}
</button>
</div>
<p class="text-xs text-purple-700 dark:text-purple-300">
{{ currentApiKeyModeDescription }}
</p>
</div>
<div <div
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100" class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
@@ -2970,7 +2995,9 @@
<p class="font-medium"><i class="fas fa-lightbulb mr-1" />小提示</p> <p class="font-medium"><i class="fas fa-lightbulb mr-1" />小提示</p>
<ul class="mt-1 list-disc space-y-1 pl-4"> <ul class="mt-1 list-disc space-y-1 pl-4">
<li>系统会为新的 Key 自动建立粘性映射,保持同一会话命中同一个 Key。</li> <li>系统会为新的 Key 自动建立粘性映射,保持同一会话命中同一个 Key。</li>
<li>勾选“清空”后保存即彻底移除旧 Key可用于紧急轮换或封禁处理。</li> <li>追加模式会保留现有 Key 并在末尾追加新的 Key。</li>
<li>覆盖模式会先清空旧 Key 再写入上方的新列表。</li>
<li>删除模式会根据输入精准移除指定 Key适合快速处理失效或被封禁的 Key。</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -3283,7 +3310,7 @@ const form = ref({
accessToken: '', accessToken: '',
refreshToken: '', refreshToken: '',
apiKeysInput: '', apiKeysInput: '',
clearExistingApiKeys: false, apiKeyUpdateMode: 'append',
proxy: initProxyConfig(), proxy: initProxyConfig(),
// Claude Console 特定字段 // Claude Console 特定字段
apiUrl: props.account?.apiUrl || '', apiUrl: props.account?.apiUrl || '',
@@ -3397,6 +3424,47 @@ const parseApiKeysInput = (input) => {
return uniqueKeys return uniqueKeys
} }
const apiKeyModeOptions = [
{
value: 'append',
label: '追加模式',
description: '保留现有 Key并在末尾追加新 Key 列表。'
},
{
value: 'replace',
label: '覆盖模式',
description: '先清空旧 Key再写入上方的新 Key 列表。'
},
{
value: 'delete',
label: '删除模式',
description: '输入要移除的 Key可精准删除失效或被封禁的 Key。'
}
]
const apiKeyModeSliderStyle = computed(() => {
const index = Math.max(
apiKeyModeOptions.findIndex((option) => option.value === form.value.apiKeyUpdateMode),
0
)
const widthPercent = 100 / apiKeyModeOptions.length
return {
width: `${widthPercent}%`,
left: `${index * widthPercent}%`
}
})
const currentApiKeyModeLabel = computed(() => {
const option = apiKeyModeOptions.find((item) => item.value === form.value.apiKeyUpdateMode)
return option ? option.label : apiKeyModeOptions[0].label
})
const currentApiKeyModeDescription = computed(() => {
const option = apiKeyModeOptions.find((item) => item.value === form.value.apiKeyUpdateMode)
return option ? option.description : apiKeyModeOptions[0].description
})
// 表单验证错误 // 表单验证错误
const errors = ref({ const errors = ref({
name: '', name: '',
@@ -4313,7 +4381,25 @@ const updateAccount = async () => {
if (props.account.platform === 'droid') { if (props.account.platform === 'droid') {
const trimmedApiKeysInput = form.value.apiKeysInput?.trim() || '' const trimmedApiKeysInput = form.value.apiKeysInput?.trim() || ''
const apiKeyUpdateMode = form.value.apiKeyUpdateMode || 'append'
if (apiKeyUpdateMode === 'delete') {
if (!trimmedApiKeysInput) {
errors.value.apiKeys = '请填写需要删除的 API Key'
loading.value = false
return
}
const removeApiKeys = parseApiKeysInput(trimmedApiKeysInput)
if (removeApiKeys.length === 0) {
errors.value.apiKeys = '请填写需要删除的 API Key'
loading.value = false
return
}
data.removeApiKeys = removeApiKeys
data.apiKeyUpdateMode = 'delete'
} else {
if (trimmedApiKeysInput) { if (trimmedApiKeysInput) {
const apiKeys = parseApiKeysInput(trimmedApiKeysInput) const apiKeys = parseApiKeysInput(trimmedApiKeysInput)
if (apiKeys.length === 0) { if (apiKeys.length === 0) {
@@ -4322,10 +4408,13 @@ const updateAccount = async () => {
return return
} }
data.apiKeys = apiKeys data.apiKeys = apiKeys
} else if (apiKeyUpdateMode === 'replace') {
data.apiKeys = []
} }
if (form.value.clearExistingApiKeys) { if (apiKeyUpdateMode !== 'append' || trimmedApiKeysInput) {
data.clearApiKeys = true data.apiKeyUpdateMode = apiKeyUpdateMode
}
} }
if (isEditingDroidApiKey.value) { if (isEditingDroidApiKey.value) {
@@ -4674,10 +4763,11 @@ watch(
errors.value.accessToken = '' errors.value.accessToken = ''
errors.value.refreshToken = '' errors.value.refreshToken = ''
form.value.authenticationMethod = 'api_key' form.value.authenticationMethod = 'api_key'
form.value.apiKeyUpdateMode = 'append'
} else if (oldType === 'apikey') { } else if (oldType === 'apikey') {
// 切换离开 API Key 模式时重置 API Key 输入 // 切换离开 API Key 模式时重置 API Key 输入
form.value.apiKeysInput = '' form.value.apiKeysInput = ''
form.value.clearExistingApiKeys = false form.value.apiKeyUpdateMode = 'append'
errors.value.apiKeys = '' errors.value.apiKeys = ''
if (!isEdit.value) { if (!isEdit.value) {
form.value.authenticationMethod = '' form.value.authenticationMethod = ''
@@ -4686,6 +4776,20 @@ watch(
} }
) )
// 监听 API Key 更新模式切换,自动清理提示
watch(
() => form.value.apiKeyUpdateMode,
(newMode, oldMode) => {
if (newMode === oldMode) {
return
}
if (errors.value.apiKeys) {
errors.value.apiKeys = ''
}
}
)
// 监听 API Key 输入,自动清理错误提示 // 监听 API Key 输入,自动清理错误提示
watch( watch(
() => form.value.apiKeysInput, () => form.value.apiKeysInput,
@@ -4694,7 +4798,22 @@ watch(
return return
} }
if (parseApiKeysInput(newValue).length > 0) { const parsed = parseApiKeysInput(newValue)
const mode = form.value.apiKeyUpdateMode
if (mode === 'append' && parsed.length > 0) {
errors.value.apiKeys = ''
return
}
if (mode === 'replace') {
if (parsed.length > 0 || !newValue || newValue.trim() === '') {
errors.value.apiKeys = ''
}
return
}
if (mode === 'delete' && parsed.length > 0) {
errors.value.apiKeys = '' errors.value.apiKeys = ''
} }
} }
@@ -4830,6 +4949,16 @@ watch(
initModelMappings() initModelMappings()
// 重新初始化代理配置 // 重新初始化代理配置
const proxyConfig = normalizeProxyFormState(newAccount.proxy) const proxyConfig = normalizeProxyFormState(newAccount.proxy)
const normalizedAuthMethod =
typeof newAccount.authenticationMethod === 'string'
? newAccount.authenticationMethod.trim().toLowerCase()
: ''
const derivedAddType =
normalizedAuthMethod === 'api_key'
? 'apikey'
: normalizedAuthMethod === 'manual'
? 'manual'
: 'oauth'
// 获取分组ID - 可能来自 groupId 字段或 groupInfo 对象 // 获取分组ID - 可能来自 groupId 字段或 groupInfo 对象
let groupId = '' let groupId = ''
@@ -4858,7 +4987,7 @@ watch(
form.value = { form.value = {
platform: newAccount.platform, platform: newAccount.platform,
addType: 'oauth', addType: derivedAddType,
name: newAccount.name, name: newAccount.name,
description: newAccount.description || '', description: newAccount.description || '',
accountType: newAccount.accountType || 'shared', accountType: newAccount.accountType || 'shared',
@@ -4872,6 +5001,9 @@ watch(
projectId: newAccount.projectId || '', projectId: newAccount.projectId || '',
accessToken: '', accessToken: '',
refreshToken: '', refreshToken: '',
authenticationMethod: newAccount.authenticationMethod || '',
apiKeysInput: '',
apiKeyUpdateMode: 'append',
proxy: proxyConfig, proxy: proxyConfig,
// Claude Console 特定字段 // Claude Console 特定字段
apiUrl: newAccount.apiUrl || '', apiUrl: newAccount.apiUrl || '',