mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat(account): 新增账户自动防护禁用开关
支持 disableAutoProtection 配置项,启用后上游 401/400/429/529 错误不再自动禁用账户
This commit is contained in:
6357
pnpm-lock.yaml
generated
Normal file
6357
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
groupId,
|
groupId,
|
||||||
dailyQuota,
|
dailyQuota,
|
||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
maxConcurrentTasks
|
maxConcurrentTasks,
|
||||||
|
disableAutoProtection
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
if (!name || !apiUrl || !apiKey) {
|
if (!name || !apiUrl || !apiKey) {
|
||||||
@@ -151,6 +152,10 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验上游错误自动防护开关
|
||||||
|
const normalizedDisableAutoProtection =
|
||||||
|
disableAutoProtection === true || disableAutoProtection === 'true'
|
||||||
|
|
||||||
// 验证accountType的有效性
|
// 验证accountType的有效性
|
||||||
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) {
|
||||||
return res
|
return res
|
||||||
@@ -180,7 +185,8 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
|
|||||||
maxConcurrentTasks:
|
maxConcurrentTasks:
|
||||||
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
maxConcurrentTasks !== undefined && maxConcurrentTasks !== null
|
||||||
? Number(maxConcurrentTasks)
|
? Number(maxConcurrentTasks)
|
||||||
: 0
|
: 0,
|
||||||
|
disableAutoProtection: normalizedDisableAutoProtection
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
// 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组)
|
||||||
@@ -250,6 +256,13 @@ router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req,
|
|||||||
return res.status(404).json({ error: 'Account not found' })
|
return res.status(404).json({ error: 'Account not found' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 规范化上游错误自动防护开关
|
||||||
|
if (mappedUpdates.disableAutoProtection !== undefined) {
|
||||||
|
mappedUpdates.disableAutoProtection =
|
||||||
|
mappedUpdates.disableAutoProtection === true ||
|
||||||
|
mappedUpdates.disableAutoProtection === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
// 处理分组的变更
|
// 处理分组的变更
|
||||||
if (mappedUpdates.accountType !== undefined) {
|
if (mappedUpdates.accountType !== undefined) {
|
||||||
// 如果之前是分组类型,需要从所有分组中移除
|
// 如果之前是分组类型,需要从所有分组中移除
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ class ClaudeConsoleAccountService {
|
|||||||
schedulable = true, // 是否可被调度
|
schedulable = true, // 是否可被调度
|
||||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||||
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
quotaResetTime = '00:00', // 额度重置时间(HH:mm格式)
|
||||||
maxConcurrentTasks = 0 // 最大并发任务数,0表示无限制
|
maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制
|
||||||
|
disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用)
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
// 验证必填字段
|
// 验证必填字段
|
||||||
@@ -115,7 +116,8 @@ class ClaudeConsoleAccountService {
|
|||||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||||
quotaResetTime, // 额度重置时间
|
quotaResetTime, // 额度重置时间
|
||||||
quotaStoppedAt: '', // 因额度停用的时间
|
quotaStoppedAt: '', // 因额度停用的时间
|
||||||
maxConcurrentTasks: maxConcurrentTasks.toString() // 最大并发任务数,0表示无限制
|
maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制
|
||||||
|
disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = redis.getClientSafe()
|
const client = redis.getClientSafe()
|
||||||
@@ -153,6 +155,7 @@ class ClaudeConsoleAccountService {
|
|||||||
quotaResetTime,
|
quotaResetTime,
|
||||||
quotaStoppedAt: null,
|
quotaStoppedAt: null,
|
||||||
maxConcurrentTasks, // 新增:返回并发限制配置
|
maxConcurrentTasks, // 新增:返回并发限制配置
|
||||||
|
disableAutoProtection, // 新增:返回自动防护开关
|
||||||
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
activeTaskCount: 0 // 新增:新建账户当前并发数为0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +216,8 @@ class ClaudeConsoleAccountService {
|
|||||||
|
|
||||||
// 并发控制相关
|
// 并发控制相关
|
||||||
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0,
|
||||||
activeTaskCount
|
activeTaskCount,
|
||||||
|
disableAutoProtection: accountData.disableAutoProtection === 'true'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,6 +263,7 @@ class ClaudeConsoleAccountService {
|
|||||||
}
|
}
|
||||||
accountData.isActive = accountData.isActive === 'true'
|
accountData.isActive = accountData.isActive === 'true'
|
||||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
||||||
|
accountData.disableAutoProtection = accountData.disableAutoProtection === 'true'
|
||||||
|
|
||||||
if (accountData.proxy) {
|
if (accountData.proxy) {
|
||||||
accountData.proxy = JSON.parse(accountData.proxy)
|
accountData.proxy = JSON.parse(accountData.proxy)
|
||||||
@@ -367,6 +372,9 @@ class ClaudeConsoleAccountService {
|
|||||||
if (updates.maxConcurrentTasks !== undefined) {
|
if (updates.maxConcurrentTasks !== undefined) {
|
||||||
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
|
updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString()
|
||||||
}
|
}
|
||||||
|
if (updates.disableAutoProtection !== undefined) {
|
||||||
|
updatedData.disableAutoProtection = updates.disableAutoProtection.toString()
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
// ✅ 直接保存 subscriptionExpiresAt(如果提供)
|
||||||
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
// Claude Console 没有 token 刷新逻辑,不会覆盖此字段
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class ClaudeConsoleRelayService {
|
|||||||
throw new Error('Claude Console Claude account not found')
|
throw new Error('Claude Console Claude account not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const autoProtectionDisabled = account.disableAutoProtection === true
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
|
`📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}`
|
||||||
)
|
)
|
||||||
@@ -248,27 +250,41 @@ class ClaudeConsoleRelayService {
|
|||||||
|
|
||||||
// 检查错误状态并相应处理
|
// 检查错误状态并相应处理
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
logger.warn(`🚫 Unauthorized error detected for Claude Console account ${accountId}`)
|
logger.warn(
|
||||||
|
`🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||||
|
}
|
||||||
} else if (accountDisabledError) {
|
} else if (accountDisabledError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
|
`🚫 Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
)
|
)
|
||||||
// 传入完整的错误详情到 webhook
|
// 传入完整的错误详情到 webhook
|
||||||
const errorDetails =
|
const errorDetails =
|
||||||
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
typeof response.data === 'string' ? response.data : JSON.stringify(response.data)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
|
await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails)
|
||||||
|
}
|
||||||
} else if (response.status === 429) {
|
} else if (response.status === 429) {
|
||||||
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
|
logger.warn(
|
||||||
|
`🚫 Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
// 收到429先检查是否因为超过了手动配置的每日额度
|
// 收到429先检查是否因为超过了手动配置的每日额度
|
||||||
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||||
|
}
|
||||||
} else if (response.status === 529) {
|
} else if (response.status === 529) {
|
||||||
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
|
logger.warn(
|
||||||
|
`🚫 Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||||
|
}
|
||||||
} else if (response.status === 200 || response.status === 201) {
|
} else if (response.status === 200 || response.status === 201) {
|
||||||
// 如果请求成功,检查并移除错误状态
|
// 如果请求成功,检查并移除错误状态
|
||||||
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId)
|
||||||
@@ -597,6 +613,7 @@ class ClaudeConsoleRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
response.data.on('end', async () => {
|
response.data.on('end', async () => {
|
||||||
|
const autoProtectionDisabled = account.disableAutoProtection === true
|
||||||
// 记录原始错误消息到日志(方便调试,包含供应商信息)
|
// 记录原始错误消息到日志(方便调试,包含供应商信息)
|
||||||
logger.error(
|
logger.error(
|
||||||
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
|
`📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}`
|
||||||
@@ -609,25 +626,42 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
|
logger.warn(
|
||||||
|
`🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
|
||||||
|
}
|
||||||
} else if (accountDisabledError) {
|
} else if (accountDisabledError) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}, marking as blocked`
|
`🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
)
|
)
|
||||||
// 传入完整的错误详情到 webhook
|
// 传入完整的错误详情到 webhook
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
await claudeConsoleAccountService.markConsoleAccountBlocked(
|
await claudeConsoleAccountService.markConsoleAccountBlocked(
|
||||||
accountId,
|
accountId,
|
||||||
errorDataForCheck
|
errorDataForCheck
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else if (response.status === 429) {
|
} else if (response.status === 429) {
|
||||||
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
logger.warn(
|
||||||
|
`🚫 [Stream] Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
// 检查是否因为超过每日额度
|
// 检查是否因为超过每日额度
|
||||||
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
|
||||||
logger.error('❌ Failed to check quota after 429 error:', err)
|
logger.error('❌ Failed to check quota after 429 error:', err)
|
||||||
})
|
})
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
|
await claudeConsoleAccountService.markAccountRateLimited(accountId)
|
||||||
|
}
|
||||||
} else if (response.status === 529) {
|
} else if (response.status === 529) {
|
||||||
|
logger.warn(
|
||||||
|
`🚫 [Stream] Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}`
|
||||||
|
)
|
||||||
|
if (!autoProtectionDisabled) {
|
||||||
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
await claudeConsoleAccountService.markAccountOverloaded(accountId)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 设置响应头
|
// 设置响应头
|
||||||
if (!responseStream.headersSent) {
|
if (!responseStream.headersSent) {
|
||||||
|
|||||||
@@ -1451,6 +1451,26 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 上游错误处理 -->
|
||||||
|
<div v-if="form.platform === 'claude-console'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>上游错误处理</label
|
||||||
|
>
|
||||||
|
<label class="inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.disableAutoProtection"
|
||||||
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
上游错误不自动暂停调度
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OpenAI-Responses 特定字段 -->
|
<!-- OpenAI-Responses 特定字段 -->
|
||||||
@@ -3070,6 +3090,26 @@
|
|||||||
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
|
<p class="mt-1 text-xs text-gray-500">账号被限流后暂停调度的时间(分钟)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 上游错误处理(编辑模式)-->
|
||||||
|
<div v-if="form.platform === 'claude-console'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
上游错误处理
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="form.disableAutoProtection"
|
||||||
|
class="mr-2 rounded border-gray-300 text-blue-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
上游错误不自动暂停调度
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
勾选后遇到 401/400/429/529 等上游错误仅记录日志并透传,不自动禁用或限流
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
<!-- OpenAI-Responses 特定字段(编辑模式)-->
|
||||||
@@ -3912,6 +3952,7 @@ const form = ref({
|
|||||||
})(),
|
})(),
|
||||||
userAgent: props.account?.userAgent || '',
|
userAgent: props.account?.userAgent || '',
|
||||||
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
|
enableRateLimit: props.account ? props.account.rateLimitDuration > 0 : true,
|
||||||
|
disableAutoProtection: props.account?.disableAutoProtection === true,
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
dailyQuota: props.account?.dailyQuota || 0,
|
dailyQuota: props.account?.dailyQuota || 0,
|
||||||
dailyUsage: props.account?.dailyUsage || 0,
|
dailyUsage: props.account?.dailyUsage || 0,
|
||||||
@@ -5015,6 +5056,10 @@ const createAccount = async () => {
|
|||||||
data.userAgent = form.value.userAgent || null
|
data.userAgent = form.value.userAgent || null
|
||||||
// 如果不启用限流,传递 0 表示不限流
|
// 如果不启用限流,传递 0 表示不限流
|
||||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||||
|
// 上游错误处理(仅 Claude Console)
|
||||||
|
if (form.value.platform === 'claude-console') {
|
||||||
|
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||||
|
}
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||||
@@ -5343,6 +5388,8 @@ const updateAccount = async () => {
|
|||||||
data.userAgent = form.value.userAgent || null
|
data.userAgent = form.value.userAgent || null
|
||||||
// 如果不启用限流,传递 0 表示不限流
|
// 如果不启用限流,传递 0 表示不限流
|
||||||
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
|
||||||
|
// 上游错误处理
|
||||||
|
data.disableAutoProtection = !!form.value.disableAutoProtection
|
||||||
// 额度管理字段
|
// 额度管理字段
|
||||||
data.dailyQuota = form.value.dailyQuota || 0
|
data.dailyQuota = form.value.dailyQuota || 0
|
||||||
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
data.quotaResetTime = form.value.quotaResetTime || '00:00'
|
||||||
@@ -5964,7 +6011,9 @@ watch(
|
|||||||
dailyUsage: newAccount.dailyUsage || 0,
|
dailyUsage: newAccount.dailyUsage || 0,
|
||||||
quotaResetTime: newAccount.quotaResetTime || '00:00',
|
quotaResetTime: newAccount.quotaResetTime || '00:00',
|
||||||
// 并发控制字段
|
// 并发控制字段
|
||||||
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0
|
maxConcurrentTasks: newAccount.maxConcurrentTasks || 0,
|
||||||
|
// 上游错误处理
|
||||||
|
disableAutoProtection: newAccount.disableAutoProtection === true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是Claude Console账户,加载实时使用情况
|
// 如果是Claude Console账户,加载实时使用情况
|
||||||
|
|||||||
Reference in New Issue
Block a user