mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 20:41:03 +00:00
feat: Droid平台支持多apikey添加
This commit is contained in:
@@ -133,6 +133,161 @@ class DroidAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_parseApiKeyEntries(rawEntries) {
|
||||||
|
if (!rawEntries) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(rawEntries)) {
|
||||||
|
return rawEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawEntries === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawEntries)
|
||||||
|
return Array.isArray(parsed) ? parsed : []
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('⚠️ Failed to parse Droid API Key entries:', error.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildApiKeyEntries(apiKeys, existingEntries = [], clearExisting = false) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const normalizedExisting = Array.isArray(existingEntries) ? existingEntries : []
|
||||||
|
|
||||||
|
const entries = clearExisting
|
||||||
|
? []
|
||||||
|
: normalizedExisting
|
||||||
|
.filter((entry) => entry && entry.id && entry.encryptedKey)
|
||||||
|
.map((entry) => ({ ...entry }))
|
||||||
|
|
||||||
|
const hashSet = new Set(entries.map((entry) => entry.hash).filter(Boolean))
|
||||||
|
|
||||||
|
if (!Array.isArray(apiKeys) || apiKeys.length === 0) {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawKey of apiKeys) {
|
||||||
|
if (typeof rawKey !== 'string') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = rawKey.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = crypto.createHash('sha256').update(trimmed).digest('hex')
|
||||||
|
if (hashSet.has(hash)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hashSet.add(hash)
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
id: uuidv4(),
|
||||||
|
hash,
|
||||||
|
encryptedKey: this._encryptSensitiveData(trimmed),
|
||||||
|
createdAt: now,
|
||||||
|
lastUsedAt: '',
|
||||||
|
usageCount: '0'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
_maskApiKeyEntries(entries) {
|
||||||
|
if (!Array.isArray(entries)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
id: entry.id,
|
||||||
|
createdAt: entry.createdAt || '',
|
||||||
|
lastUsedAt: entry.lastUsedAt || '',
|
||||||
|
usageCount: entry.usageCount || '0'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
_decryptApiKeyEntry(entry) {
|
||||||
|
if (!entry || !entry.encryptedKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = this._decryptSensitiveData(entry.encryptedKey)
|
||||||
|
if (!apiKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageCountNumber = Number(entry.usageCount)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
key: apiKey,
|
||||||
|
hash: entry.hash || '',
|
||||||
|
createdAt: entry.createdAt || '',
|
||||||
|
lastUsedAt: entry.lastUsedAt || '',
|
||||||
|
usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDecryptedApiKeyEntries(accountId) {
|
||||||
|
if (!accountId) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountData = await redis.getDroidAccount(accountId)
|
||||||
|
if (!accountData) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = this._parseApiKeyEntries(accountData.apiKeys)
|
||||||
|
return entries
|
||||||
|
.map((entry) => this._decryptApiKeyEntry(entry))
|
||||||
|
.filter((entry) => entry && entry.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
async touchApiKeyUsage(accountId, keyId) {
|
||||||
|
if (!accountId || !keyId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountData = await redis.getDroidAccount(accountId)
|
||||||
|
if (!accountData) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = this._parseApiKeyEntries(accountData.apiKeys)
|
||||||
|
const index = entries.findIndex((entry) => entry.id === keyId)
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedEntry = { ...entries[index] }
|
||||||
|
updatedEntry.lastUsedAt = new Date().toISOString()
|
||||||
|
const usageCount = Number(updatedEntry.usageCount)
|
||||||
|
updatedEntry.usageCount = String(
|
||||||
|
Number.isFinite(usageCount) && usageCount >= 0 ? usageCount + 1 : 1
|
||||||
|
)
|
||||||
|
|
||||||
|
entries[index] = updatedEntry
|
||||||
|
|
||||||
|
accountData.apiKeys = JSON.stringify(entries)
|
||||||
|
accountData.apiKeyCount = String(entries.length)
|
||||||
|
|
||||||
|
await redis.setDroidAccount(accountId, accountData)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ Failed to update API key usage for Droid account ${accountId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 使用 WorkOS Refresh Token 刷新并验证凭证
|
* 使用 WorkOS Refresh Token 刷新并验证凭证
|
||||||
*/
|
*/
|
||||||
@@ -275,7 +430,8 @@ class DroidAccountService {
|
|||||||
userId = '',
|
userId = '',
|
||||||
tokenType = 'Bearer',
|
tokenType = 'Bearer',
|
||||||
authenticationMethod = '',
|
authenticationMethod = '',
|
||||||
expiresIn = null
|
expiresIn = null,
|
||||||
|
apiKeys = []
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -296,15 +452,40 @@ class DroidAccountService {
|
|||||||
let lastRefreshAt = accessToken ? new Date().toISOString() : ''
|
let lastRefreshAt = accessToken ? new Date().toISOString() : ''
|
||||||
let status = accessToken ? 'active' : 'created'
|
let status = accessToken ? 'active' : 'created'
|
||||||
|
|
||||||
const isManualProvision =
|
const apiKeyEntries = this._buildApiKeyEntries(apiKeys)
|
||||||
typeof authenticationMethod === 'string' &&
|
const hasApiKeys = apiKeyEntries.length > 0
|
||||||
authenticationMethod.toLowerCase().trim() === 'manual'
|
|
||||||
|
|
||||||
const provisioningMode = isManualProvision ? 'manual' : 'oauth'
|
if (hasApiKeys) {
|
||||||
|
normalizedAuthenticationMethod = 'api_key'
|
||||||
|
normalizedAccessToken = ''
|
||||||
|
normalizedRefreshToken = ''
|
||||||
|
normalizedExpiresAt = ''
|
||||||
|
normalizedExpiresIn = null
|
||||||
|
lastRefreshAt = ''
|
||||||
|
status = 'active'
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedAuthMethod =
|
||||||
|
typeof normalizedAuthenticationMethod === 'string'
|
||||||
|
? normalizedAuthenticationMethod.toLowerCase().trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const isApiKeyProvision = normalizedAuthMethod === 'api_key'
|
||||||
|
const isManualProvision = normalizedAuthMethod === 'manual'
|
||||||
|
|
||||||
|
const provisioningMode = isApiKeyProvision ? 'api_key' : isManualProvision ? 'manual' : 'oauth'
|
||||||
|
|
||||||
|
if (isApiKeyProvision) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔍 [Droid ${provisioningMode}] 初始令牌 - AccountName: ${name}, AccessToken: ${normalizedAccessToken || '[empty]'}, RefreshToken: ${normalizedRefreshToken || '[empty]'}`
|
`🔍 [Droid api_key] 初始密钥 - AccountName: ${name}, KeyCount: ${apiKeyEntries.length}`
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`🔍 [Droid ${provisioningMode}] 初始令牌 - AccountName: ${name}, AccessToken: ${
|
||||||
|
normalizedAccessToken || '[empty]'
|
||||||
|
}, RefreshToken: ${normalizedRefreshToken || '[empty]'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let proxyConfig = null
|
let proxyConfig = null
|
||||||
if (proxy && typeof proxy === 'object') {
|
if (proxy && typeof proxy === 'object') {
|
||||||
@@ -318,7 +499,7 @@ class DroidAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedRefreshToken && isManualProvision) {
|
if (!isApiKeyProvision && normalizedRefreshToken && isManualProvision) {
|
||||||
try {
|
try {
|
||||||
const refreshed = await this._refreshTokensWithWorkOS(normalizedRefreshToken, proxyConfig)
|
const refreshed = await this._refreshTokensWithWorkOS(normalizedRefreshToken, proxyConfig)
|
||||||
|
|
||||||
@@ -381,7 +562,7 @@ class DroidAccountService {
|
|||||||
logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error)
|
logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error)
|
||||||
throw new Error(`Refresh Token 验证失败:${error.message}`)
|
throw new Error(`Refresh Token 验证失败:${error.message}`)
|
||||||
}
|
}
|
||||||
} else if (normalizedRefreshToken && !isManualProvision) {
|
} else if (!isApiKeyProvision && normalizedRefreshToken && !isManualProvision) {
|
||||||
try {
|
try {
|
||||||
const orgIds = await this._fetchFactoryOrgIds(normalizedAccessToken, proxyConfig)
|
const orgIds = await this._fetchFactoryOrgIds(normalizedAccessToken, proxyConfig)
|
||||||
const selectedOrgId =
|
const selectedOrgId =
|
||||||
@@ -460,7 +641,7 @@ class DroidAccountService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!normalizedExpiresAt) {
|
if (!isApiKeyProvision && !normalizedExpiresAt) {
|
||||||
let expiresInSeconds = null
|
let expiresInSeconds = null
|
||||||
if (typeof normalizedExpiresIn === 'number' && Number.isFinite(normalizedExpiresIn)) {
|
if (typeof normalizedExpiresIn === 'number' && Number.isFinite(normalizedExpiresIn)) {
|
||||||
expiresInSeconds = normalizedExpiresIn
|
expiresInSeconds = normalizedExpiresIn
|
||||||
@@ -519,7 +700,10 @@ class DroidAccountService {
|
|||||||
expiresIn:
|
expiresIn:
|
||||||
normalizedExpiresIn !== null && normalizedExpiresIn !== undefined
|
normalizedExpiresIn !== null && normalizedExpiresIn !== undefined
|
||||||
? String(normalizedExpiresIn)
|
? String(normalizedExpiresIn)
|
||||||
: ''
|
: '',
|
||||||
|
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
|
||||||
|
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
|
||||||
|
apiKeyStrategy: hasApiKeys ? 'random_sticky' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.setDroidAccount(accountId, accountData)
|
await redis.setDroidAccount(accountId, accountData)
|
||||||
@@ -551,12 +735,16 @@ class DroidAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 解密敏感数据
|
// 解密敏感数据
|
||||||
|
const apiKeyEntries = this._parseApiKeyEntries(account.apiKeys)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...account,
|
...account,
|
||||||
id: accountId,
|
id: accountId,
|
||||||
endpointType: this._sanitizeEndpointType(account.endpointType),
|
endpointType: this._sanitizeEndpointType(account.endpointType),
|
||||||
refreshToken: this._decryptSensitiveData(account.refreshToken),
|
refreshToken: this._decryptSensitiveData(account.refreshToken),
|
||||||
accessToken: this._decryptSensitiveData(account.accessToken)
|
accessToken: this._decryptSensitiveData(account.accessToken),
|
||||||
|
apiKeys: this._maskApiKeyEntries(apiKeyEntries),
|
||||||
|
apiKeyCount: apiKeyEntries.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +760,15 @@ class DroidAccountService {
|
|||||||
refreshToken: account.refreshToken ? '***ENCRYPTED***' : '',
|
refreshToken: account.refreshToken ? '***ENCRYPTED***' : '',
|
||||||
accessToken: account.accessToken
|
accessToken: account.accessToken
|
||||||
? maskToken(this._decryptSensitiveData(account.accessToken))
|
? maskToken(this._decryptSensitiveData(account.accessToken))
|
||||||
: ''
|
: '',
|
||||||
|
apiKeyCount: (() => {
|
||||||
|
const parsedCount = this._parseApiKeyEntries(account.apiKeys).length
|
||||||
|
if (account.apiKeyCount === undefined || account.apiKeyCount === null) {
|
||||||
|
return parsedCount
|
||||||
|
}
|
||||||
|
const numeric = Number(account.apiKeyCount)
|
||||||
|
return Number.isFinite(numeric) && numeric >= 0 ? numeric : parsedCount
|
||||||
|
})()
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,6 +902,46 @@ class DroidAccountService {
|
|||||||
sanitizedUpdates.proxy = account.proxy || ''
|
sanitizedUpdates.proxy = account.proxy || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingApiKeyEntries = this._parseApiKeyEntries(account.apiKeys)
|
||||||
|
const newApiKeysInput = Array.isArray(updates.apiKeys) ? updates.apiKeys : []
|
||||||
|
const wantsClearApiKeys = Boolean(updates.clearApiKeys)
|
||||||
|
|
||||||
|
if (sanitizedUpdates.apiKeys !== undefined) {
|
||||||
|
delete sanitizedUpdates.apiKeys
|
||||||
|
}
|
||||||
|
if (sanitizedUpdates.clearApiKeys !== undefined) {
|
||||||
|
delete sanitizedUpdates.clearApiKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsClearApiKeys || newApiKeysInput.length > 0) {
|
||||||
|
const mergedApiKeys = this._buildApiKeyEntries(
|
||||||
|
newApiKeysInput,
|
||||||
|
existingApiKeyEntries,
|
||||||
|
wantsClearApiKeys
|
||||||
|
)
|
||||||
|
|
||||||
|
const baselineCount = wantsClearApiKeys ? 0 : existingApiKeyEntries.length
|
||||||
|
const addedCount = Math.max(mergedApiKeys.length - baselineCount, 0)
|
||||||
|
|
||||||
|
sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : ''
|
||||||
|
sanitizedUpdates.apiKeyCount = String(mergedApiKeys.length)
|
||||||
|
|
||||||
|
if (mergedApiKeys.length > 0) {
|
||||||
|
sanitizedUpdates.authenticationMethod = 'api_key'
|
||||||
|
sanitizedUpdates.status = sanitizedUpdates.status || 'active'
|
||||||
|
logger.info(
|
||||||
|
`🔑 Updated Droid API keys for ${accountId}: total ${mergedApiKeys.length} (added ${addedCount})`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.info(`🔑 Cleared all API keys for Droid account ${accountId}`)
|
||||||
|
// 如果完全移除 API Key,可根据是否仍有 token 来确定认证方式
|
||||||
|
if (!sanitizedUpdates.accessToken && !account.accessToken) {
|
||||||
|
sanitizedUpdates.authenticationMethod =
|
||||||
|
account.authenticationMethod === 'api_key' ? '' : account.authenticationMethod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const encryptedUpdates = { ...sanitizedUpdates }
|
const encryptedUpdates = { ...sanitizedUpdates }
|
||||||
|
|
||||||
if (sanitizedUpdates.refreshToken !== undefined) {
|
if (sanitizedUpdates.refreshToken !== undefined) {
|
||||||
@@ -866,6 +1102,13 @@ class DroidAccountService {
|
|||||||
throw new Error(`Droid account not found: ${accountId}`)
|
throw new Error(`Droid account not found: ${accountId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof account.authenticationMethod === 'string' &&
|
||||||
|
account.authenticationMethod.toLowerCase().trim() === 'api_key'
|
||||||
|
) {
|
||||||
|
throw new Error(`Droid account ${accountId} 已配置为 API Key 模式,不能获取 Access Token`)
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否需要刷新
|
// 检查是否需要刷新
|
||||||
if (this.shouldRefreshToken(account)) {
|
if (this.shouldRefreshToken(account)) {
|
||||||
logger.info(`🔄 Droid account token needs refresh: ${accountId}`)
|
logger.info(`🔄 Droid account token needs refresh: ${accountId}`)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class DroidRelayService {
|
|||||||
this.userAgent = 'factory-cli/0.19.4'
|
this.userAgent = 'factory-cli/0.19.4'
|
||||||
this.systemPrompt = SYSTEM_PROMPT
|
this.systemPrompt = SYSTEM_PROMPT
|
||||||
this.modelReasoningMap = new Map()
|
this.modelReasoningMap = new Map()
|
||||||
|
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
|
||||||
|
|
||||||
Object.entries(MODEL_REASONING_CONFIG).forEach(([modelId, level]) => {
|
Object.entries(MODEL_REASONING_CONFIG).forEach(([modelId, level]) => {
|
||||||
if (!modelId) {
|
if (!modelId) {
|
||||||
@@ -87,6 +88,56 @@ class DroidRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_composeApiKeyStickyKey(accountId, endpointType, sessionHash) {
|
||||||
|
if (!accountId || !sessionHash) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEndpoint = this._normalizeEndpointType(endpointType)
|
||||||
|
return `${this.API_KEY_STICKY_PREFIX}:${accountId}:${normalizedEndpoint}:${sessionHash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async _selectApiKey(account, endpointType, sessionHash) {
|
||||||
|
const entries = await droidAccountService.getDecryptedApiKeyEntries(account.id)
|
||||||
|
if (!entries || entries.length === 0) {
|
||||||
|
throw new Error(`Droid account ${account.id} 未配置任何 API Key`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stickyKey = this._composeApiKeyStickyKey(account.id, endpointType, sessionHash)
|
||||||
|
|
||||||
|
if (stickyKey) {
|
||||||
|
const mappedKeyId = await redis.getSessionAccountMapping(stickyKey)
|
||||||
|
if (mappedKeyId) {
|
||||||
|
const mappedEntry = entries.find((entry) => entry.id === mappedKeyId)
|
||||||
|
if (mappedEntry) {
|
||||||
|
await redis.extendSessionAccountMappingTTL(stickyKey)
|
||||||
|
await droidAccountService.touchApiKeyUsage(account.id, mappedEntry.id)
|
||||||
|
logger.info(`🔐 使用已绑定的 Droid API Key ${mappedEntry.id}(Account: ${account.id})`)
|
||||||
|
return mappedEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.deleteSessionAccountMapping(stickyKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedEntry = entries[Math.floor(Math.random() * entries.length)]
|
||||||
|
if (!selectedEntry) {
|
||||||
|
throw new Error(`Droid account ${account.id} 没有可用的 API Key`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stickyKey) {
|
||||||
|
await redis.setSessionAccountMapping(stickyKey, selectedEntry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
await droidAccountService.touchApiKeyUsage(account.id, selectedEntry.id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔐 随机选取 Droid API Key ${selectedEntry.id}(Account: ${account.id}, Keys: ${entries.length})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return selectedEntry
|
||||||
|
}
|
||||||
|
|
||||||
async relayRequest(
|
async relayRequest(
|
||||||
requestBody,
|
requestBody,
|
||||||
apiKeyData,
|
apiKeyData,
|
||||||
@@ -113,8 +164,19 @@ class DroidRelayService {
|
|||||||
throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`)
|
throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取有效的 access token(自动刷新)
|
// 获取认证凭据:支持 Access Token 和 API Key 两种模式
|
||||||
const accessToken = await droidAccountService.getValidAccessToken(account.id)
|
let selectedApiKey = null
|
||||||
|
let accessToken = null
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof account.authenticationMethod === 'string' &&
|
||||||
|
account.authenticationMethod.toLowerCase().trim() === 'api_key'
|
||||||
|
) {
|
||||||
|
selectedApiKey = await this._selectApiKey(account, normalizedEndpoint, sessionHash)
|
||||||
|
accessToken = selectedApiKey.key
|
||||||
|
} else {
|
||||||
|
accessToken = await droidAccountService.getValidAccessToken(account.id)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取 Factory.ai API URL
|
// 获取 Factory.ai API URL
|
||||||
const endpoint = this.endpoints[normalizedEndpoint]
|
const endpoint = this.endpoints[normalizedEndpoint]
|
||||||
@@ -138,6 +200,12 @@ class DroidRelayService {
|
|||||||
clientHeaders
|
clientHeaders
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (selectedApiKey) {
|
||||||
|
logger.info(
|
||||||
|
`🔑 Forwarding request with Droid API Key ${selectedApiKey.id} (Account: ${account.id})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 处理请求体(注入 system prompt 等)
|
// 处理请求体(注入 system prompt 等)
|
||||||
const processedBody = this._processRequestBody(requestBody, normalizedEndpoint)
|
const processedBody = this._processRequestBody(requestBody, normalizedEndpoint)
|
||||||
|
|
||||||
|
|||||||
@@ -559,6 +559,17 @@
|
|||||||
>手动输入 Access Token</span
|
>手动输入 Access Token</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
<label v-if="form.platform === 'droid'" class="flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.addType"
|
||||||
|
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="radio"
|
||||||
|
value="apikey"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300"
|
||||||
|
>使用 API Key (支持多个)</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1642,6 +1653,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key 模式输入 -->
|
||||||
|
<div
|
||||||
|
v-if="form.addType === 'apikey' && form.platform === 'droid'"
|
||||||
|
class="space-y-4 rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-700 dark:bg-purple-900/30"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-purple-500"
|
||||||
|
>
|
||||||
|
<i class="fas fa-key text-sm text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-2 font-semibold text-purple-900 dark:text-purple-200">
|
||||||
|
使用 API Key 调度 Droid
|
||||||
|
</h5>
|
||||||
|
<p class="text-sm text-purple-800 dark:text-purple-200">
|
||||||
|
请填写一个或多个 Factory.ai API
|
||||||
|
Key,系统会自动在请求时随机挑选并结合会话哈希维持粘性,确保对话上下文保持稳定。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>API Key 列表 *</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
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="{ 'border-red-500': errors.apiKeys }"
|
||||||
|
placeholder="每行一个 API Key,可粘贴多行"
|
||||||
|
required
|
||||||
|
rows="6"
|
||||||
|
/>
|
||||||
|
<p v-if="errors.apiKeys" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ errors.apiKeys }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<i class="fas fa-info-circle mr-1" />
|
||||||
|
建议为每条 Key 提供独立额度;系统会自动去重并忽略空白行。
|
||||||
|
</p>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<p class="font-medium"><i class="fas fa-random mr-1" />分配策略说明</p>
|
||||||
|
<ul class="mt-1 list-disc space-y-1 pl-4">
|
||||||
|
<li>新会话将随机命中一个 Key,并在会话有效期内保持粘性。</li>
|
||||||
|
<li>若某 Key 失效,会自动切换到剩余可用 Key,最大化成功率。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 代理设置 -->
|
<!-- 代理设置 -->
|
||||||
<ProxyConfig v-model="form.proxy" />
|
<ProxyConfig v-model="form.proxy" />
|
||||||
|
|
||||||
@@ -2701,8 +2766,73 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Token 更新 -->
|
<!-- Token 更新 -->
|
||||||
|
<div
|
||||||
|
v-if="isEdit && isEditingDroidApiKey"
|
||||||
|
class="rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-700 dark:bg-purple-900/30"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-purple-500"
|
||||||
|
>
|
||||||
|
<i class="fas fa-retweet text-sm text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-2 font-semibold text-purple-900 dark:text-purple-200">
|
||||||
|
更新 API Key
|
||||||
|
</h5>
|
||||||
|
<p class="mb-1 text-sm text-purple-800 dark:text-purple-200">
|
||||||
|
当前已保存 <strong>{{ existingApiKeyCount }}</strong> 条 API Key。您可以追加新的
|
||||||
|
Key 或使用下方选项清空后重新填写。
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-purple-700 dark:text-purple-300">
|
||||||
|
留空表示保留现有 Key 不变;填写内容后将覆盖或追加(视清空选项而定)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>新的 API Key 列表</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
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="{ 'border-red-500': errors.apiKeys }"
|
||||||
|
placeholder="留空表示不更新;每行一个 API Key"
|
||||||
|
rows="6"
|
||||||
|
/>
|
||||||
|
<p v-if="errors.apiKeys" class="mt-1 text-xs text-red-500">
|
||||||
|
{{ errors.apiKeys }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.clearExistingApiKeys"
|
||||||
|
class="rounded border-purple-300 text-purple-600 focus:ring-purple-500 dark:border-purple-500 dark:bg-purple-900"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span>清空已有 API Key 后再应用上方的 Key 列表</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<p class="font-medium"><i class="fas fa-lightbulb mr-1" />小提示</p>
|
||||||
|
<ul class="mt-1 list-disc space-y-1 pl-4">
|
||||||
|
<li>系统会为新的 Key 自动建立粘性映射,保持同一会话命中同一个 Key。</li>
|
||||||
|
<li>勾选“清空”后保存即彻底移除旧 Key,可用于紧急轮换或封禁处理。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
|
!(isEdit && isEditingDroidApiKey) &&
|
||||||
form.platform !== 'claude-console' &&
|
form.platform !== 'claude-console' &&
|
||||||
form.platform !== 'ccr' &&
|
form.platform !== 'ccr' &&
|
||||||
form.platform !== 'bedrock' &&
|
form.platform !== 'bedrock' &&
|
||||||
@@ -2895,6 +3025,7 @@ const form = ref({
|
|||||||
name: props.account?.name || '',
|
name: props.account?.name || '',
|
||||||
description: props.account?.description || '',
|
description: props.account?.description || '',
|
||||||
accountType: props.account?.accountType || 'shared',
|
accountType: props.account?.accountType || 'shared',
|
||||||
|
authenticationMethod: props.account?.authenticationMethod || '',
|
||||||
subscriptionType: 'claude_max', // 默认为 Claude Max,兼容旧数据
|
subscriptionType: 'claude_max', // 默认为 Claude Max,兼容旧数据
|
||||||
autoStopOnWarning: props.account?.autoStopOnWarning || false, // 5小时限制自动停止调度
|
autoStopOnWarning: props.account?.autoStopOnWarning || false, // 5小时限制自动停止调度
|
||||||
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
useUnifiedUserAgent: props.account?.useUnifiedUserAgent || false, // 使用统一Claude Code版本
|
||||||
@@ -2905,6 +3036,8 @@ const form = ref({
|
|||||||
projectId: props.account?.projectId || '',
|
projectId: props.account?.projectId || '',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
|
apiKeysInput: '',
|
||||||
|
clearExistingApiKeys: false,
|
||||||
proxy: initProxyConfig(),
|
proxy: initProxyConfig(),
|
||||||
// Claude Console 特定字段
|
// Claude Console 特定字段
|
||||||
apiUrl: props.account?.apiUrl || '',
|
apiUrl: props.account?.apiUrl || '',
|
||||||
@@ -2971,13 +3104,34 @@ const initModelMappings = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析多行 API Key 输入
|
||||||
|
const parseApiKeysInput = (input) => {
|
||||||
|
if (!input || typeof input !== 'string') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = input
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueKeys = Array.from(new Set(segments))
|
||||||
|
return uniqueKeys
|
||||||
|
}
|
||||||
|
|
||||||
// 表单验证错误
|
// 表单验证错误
|
||||||
const errors = ref({
|
const errors = ref({
|
||||||
name: '',
|
name: '',
|
||||||
refreshToken: '',
|
refreshToken: '',
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
|
apiKeys: '',
|
||||||
apiUrl: '',
|
apiUrl: '',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
|
baseApi: '',
|
||||||
accessKeyId: '',
|
accessKeyId: '',
|
||||||
secretAccessKey: '',
|
secretAccessKey: '',
|
||||||
region: '',
|
region: '',
|
||||||
@@ -3019,6 +3173,55 @@ const usagePercentage = computed(() => {
|
|||||||
return (currentUsage / form.value.dailyQuota) * 100
|
return (currentUsage / form.value.dailyQuota) * 100
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 当前账户的 API Key 数量(仅用于展示)
|
||||||
|
const existingApiKeyCount = computed(() => {
|
||||||
|
if (!props.account || props.account.platform !== 'droid') {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackList = 0
|
||||||
|
|
||||||
|
if (Array.isArray(props.account.apiKeys)) {
|
||||||
|
fallbackList = props.account.apiKeys.length
|
||||||
|
} else if (typeof props.account.apiKeys === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(props.account.apiKeys)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
fallbackList = parsed.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fallbackList = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count =
|
||||||
|
props.account.apiKeyCount ??
|
||||||
|
props.account.apiKeysCount ??
|
||||||
|
props.account.api_key_count ??
|
||||||
|
fallbackList
|
||||||
|
|
||||||
|
return Number(count) || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 编辑时判断是否为 API Key 模式的 Droid 账户
|
||||||
|
const isEditingDroidApiKey = computed(() => {
|
||||||
|
if (!isEdit.value || form.value.platform !== 'droid') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const method =
|
||||||
|
form.value.authenticationMethod ||
|
||||||
|
props.account?.authenticationMethod ||
|
||||||
|
props.account?.authMethod ||
|
||||||
|
props.account?.authentication_mode ||
|
||||||
|
''
|
||||||
|
|
||||||
|
if (typeof method !== 'string') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return method.trim().toLowerCase() === 'api_key'
|
||||||
|
})
|
||||||
|
|
||||||
// 加载账户今日使用情况
|
// 加载账户今日使用情况
|
||||||
const loadAccountUsage = async () => {
|
const loadAccountUsage = async () => {
|
||||||
if (!isEdit.value || !props.account?.id) return
|
if (!isEdit.value || !props.account?.id) return
|
||||||
@@ -3391,6 +3594,7 @@ const createAccount = async () => {
|
|||||||
errors.value.refreshToken = ''
|
errors.value.refreshToken = ''
|
||||||
errors.value.apiUrl = ''
|
errors.value.apiUrl = ''
|
||||||
errors.value.apiKey = ''
|
errors.value.apiKey = ''
|
||||||
|
errors.value.apiKeys = ''
|
||||||
|
|
||||||
let hasError = false
|
let hasError = false
|
||||||
|
|
||||||
@@ -3493,6 +3697,12 @@ const createAccount = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Claude Console、CCR、OpenAI-Responses 等其他平台不需要 Token 验证
|
// Claude Console、CCR、OpenAI-Responses 等其他平台不需要 Token 验证
|
||||||
|
} else if (form.value.addType === 'apikey') {
|
||||||
|
const apiKeys = parseApiKeysInput(form.value.apiKeysInput)
|
||||||
|
if (apiKeys.length === 0) {
|
||||||
|
errors.value.apiKeys = '请至少填写一个 API Key'
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分组类型验证 - 创建账户流程修复
|
// 分组类型验证 - 创建账户流程修复
|
||||||
@@ -3615,6 +3825,17 @@ const createAccount = async () => {
|
|||||||
data.requireRefreshSuccess = true // 必须刷新成功才能创建账户
|
data.requireRefreshSuccess = true // 必须刷新成功才能创建账户
|
||||||
data.priority = form.value.priority || 50
|
data.priority = form.value.priority || 50
|
||||||
} else if (form.value.platform === 'droid') {
|
} else if (form.value.platform === 'droid') {
|
||||||
|
data.priority = form.value.priority || 50
|
||||||
|
data.endpointType = form.value.endpointType || 'anthropic'
|
||||||
|
data.platform = 'droid'
|
||||||
|
|
||||||
|
if (form.value.addType === 'apikey') {
|
||||||
|
const apiKeys = parseApiKeysInput(form.value.apiKeysInput)
|
||||||
|
data.apiKeys = apiKeys
|
||||||
|
data.authenticationMethod = 'api_key'
|
||||||
|
data.isActive = true
|
||||||
|
data.schedulable = true
|
||||||
|
} else {
|
||||||
const accessToken = form.value.accessToken?.trim() || ''
|
const accessToken = form.value.accessToken?.trim() || ''
|
||||||
const refreshToken = form.value.refreshToken?.trim() || ''
|
const refreshToken = form.value.refreshToken?.trim() || ''
|
||||||
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
|
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString()
|
||||||
@@ -3623,11 +3844,9 @@ const createAccount = async () => {
|
|||||||
data.refreshToken = refreshToken
|
data.refreshToken = refreshToken
|
||||||
data.expiresAt = expiresAt
|
data.expiresAt = expiresAt
|
||||||
data.expiresIn = 8 * 60 * 60
|
data.expiresIn = 8 * 60 * 60
|
||||||
data.priority = form.value.priority || 50
|
|
||||||
data.endpointType = form.value.endpointType || 'anthropic'
|
|
||||||
data.platform = 'droid'
|
|
||||||
data.tokenType = 'Bearer'
|
data.tokenType = 'Bearer'
|
||||||
data.authenticationMethod = 'manual'
|
data.authenticationMethod = 'manual'
|
||||||
|
}
|
||||||
} else if (form.value.platform === 'claude-console' || form.value.platform === 'ccr') {
|
} else if (form.value.platform === 'claude-console' || form.value.platform === 'ccr') {
|
||||||
// Claude Console 和 CCR 账户特定数据(CCR 使用 Claude Console 的后端逻辑)
|
// Claude Console 和 CCR 账户特定数据(CCR 使用 Claude Console 的后端逻辑)
|
||||||
data.apiUrl = form.value.apiUrl
|
data.apiUrl = form.value.apiUrl
|
||||||
@@ -3731,6 +3950,7 @@ const createAccount = async () => {
|
|||||||
const updateAccount = async () => {
|
const updateAccount = async () => {
|
||||||
// 清除之前的错误
|
// 清除之前的错误
|
||||||
errors.value.name = ''
|
errors.value.name = ''
|
||||||
|
errors.value.apiKeys = ''
|
||||||
|
|
||||||
// 验证账户名称
|
// 验证账户名称
|
||||||
if (!form.value.name || form.value.name.trim() === '') {
|
if (!form.value.name || form.value.name.trim() === '') {
|
||||||
@@ -3849,6 +4069,28 @@ const updateAccount = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.account.platform === 'droid') {
|
||||||
|
const trimmedApiKeysInput = form.value.apiKeysInput?.trim() || ''
|
||||||
|
|
||||||
|
if (trimmedApiKeysInput) {
|
||||||
|
const apiKeys = parseApiKeysInput(trimmedApiKeysInput)
|
||||||
|
if (apiKeys.length === 0) {
|
||||||
|
errors.value.apiKeys = '请至少填写一个 API Key'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.apiKeys = apiKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.value.clearExistingApiKeys) {
|
||||||
|
data.clearApiKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditingDroidApiKey.value) {
|
||||||
|
data.authenticationMethod = 'api_key'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (props.account.platform === 'gemini') {
|
if (props.account.platform === 'gemini') {
|
||||||
data.projectId = form.value.projectId || ''
|
data.projectId = form.value.projectId || ''
|
||||||
}
|
}
|
||||||
@@ -4175,6 +4417,47 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听添加方式切换,确保字段状态同步
|
||||||
|
watch(
|
||||||
|
() => form.value.addType,
|
||||||
|
(newType, oldType) => {
|
||||||
|
if (newType === oldType) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newType === 'apikey') {
|
||||||
|
// 切换到 API Key 模式时清理 Token 字段
|
||||||
|
form.value.accessToken = ''
|
||||||
|
form.value.refreshToken = ''
|
||||||
|
errors.value.accessToken = ''
|
||||||
|
errors.value.refreshToken = ''
|
||||||
|
form.value.authenticationMethod = 'api_key'
|
||||||
|
} else if (oldType === 'apikey') {
|
||||||
|
// 切换离开 API Key 模式时重置 API Key 输入
|
||||||
|
form.value.apiKeysInput = ''
|
||||||
|
form.value.clearExistingApiKeys = false
|
||||||
|
errors.value.apiKeys = ''
|
||||||
|
if (!isEdit.value) {
|
||||||
|
form.value.authenticationMethod = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 监听 API Key 输入,自动清理错误提示
|
||||||
|
watch(
|
||||||
|
() => form.value.apiKeysInput,
|
||||||
|
(newValue) => {
|
||||||
|
if (!errors.value.apiKeys) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parseApiKeysInput(newValue).length > 0) {
|
||||||
|
errors.value.apiKeys = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 监听Setup Token授权码输入,自动提取URL中的code参数
|
// 监听Setup Token授权码输入,自动提取URL中的code参数
|
||||||
watch(setupTokenAuthCode, (newValue) => {
|
watch(setupTokenAuthCode, (newValue) => {
|
||||||
if (!newValue || typeof newValue !== 'string') return
|
if (!newValue || typeof newValue !== 'string') return
|
||||||
|
|||||||
Reference in New Issue
Block a user