feat(groups): add Claude Code client restriction and session isolation

- Add claude_code_only field to restrict groups to Claude Code clients only
- Add fallback_group_id for non-Claude Code requests to use alternate group
- Implement ClaudeCodeValidator for User-Agent detection
- Add group-level session binding isolation (groupID in Redis key)
- Prevent cross-group sticky session pollution
- Update frontend with Claude Code restriction controls
This commit is contained in:
Edric Li
2026-01-08 23:07:00 +08:00
parent 958ffe7a8a
commit a42105881f
31 changed files with 1284 additions and 50 deletions

View File

@@ -857,6 +857,15 @@ export default {
imagePricing: {
title: 'Image Generation Pricing',
description: 'Configure pricing for gemini-3-pro-image model. Leave empty to use default prices.'
},
claudeCode: {
title: 'Claude Code Client Restriction',
tooltip: 'When enabled, this group only allows official Claude Code clients. Non-Claude Code requests will be rejected or fallback to the specified group.',
enabled: 'Claude Code Only',
disabled: 'Allow All Clients',
fallbackGroup: 'Fallback Group',
fallbackHint: 'Non-Claude Code requests will use this group. Leave empty to reject directly.',
noFallback: 'No Fallback (Reject)'
}
},

View File

@@ -934,6 +934,15 @@ export default {
imagePricing: {
title: '图片生成计费',
description: '配置 gemini-3-pro-image 模型的图片生成价格,留空则使用默认价格'
},
claudeCode: {
title: 'Claude Code 客户端限制',
tooltip: '启用后,此分组仅允许 Claude Code 官方客户端访问。非 Claude Code 请求将被拒绝或降级到指定分组。',
enabled: '仅限 Claude Code',
disabled: '允许所有客户端',
fallbackGroup: '降级分组',
fallbackHint: '非 Claude Code 请求将使用此分组,留空则直接拒绝',
noFallback: '不降级(直接拒绝)'
}
},

View File

@@ -263,6 +263,9 @@ export interface Group {
image_price_1k: number | null
image_price_2k: number | null
image_price_4k: number | null
// Claude Code 客户端限制
claude_code_only: boolean
fallback_group_id: number | null
account_count?: number
created_at: string
updated_at: string
@@ -298,6 +301,15 @@ export interface CreateGroupRequest {
platform?: GroupPlatform
rate_multiplier?: number
is_exclusive?: boolean
subscription_type?: SubscriptionType
daily_limit_usd?: number | null
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
image_price_1k?: number | null
image_price_2k?: number | null
image_price_4k?: number | null
claude_code_only?: boolean
fallback_group_id?: number | null
}
export interface UpdateGroupRequest {
@@ -307,6 +319,15 @@ export interface UpdateGroupRequest {
rate_multiplier?: number
is_exclusive?: boolean
status?: 'active' | 'inactive'
subscription_type?: SubscriptionType
daily_limit_usd?: number | null
weekly_limit_usd?: number | null
monthly_limit_usd?: number | null
image_price_1k?: number | null
image_price_2k?: number | null
image_price_4k?: number | null
claude_code_only?: boolean
fallback_group_id?: number | null
}
// ==================== Account & Proxy Types ====================

View File

@@ -403,6 +403,62 @@
</div>
</div>
<!-- Claude Code 客户端限制 anthropic 平台 -->
<div v-if="createForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.claudeCode.title') }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.claudeCode.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="createForm.claude_code_only = !createForm.claude_code_only"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
createForm.claude_code_only ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
createForm.claude_code_only ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ createForm.claude_code_only ? t('admin.groups.claudeCode.enabled') : t('admin.groups.claudeCode.disabled') }}
</span>
</div>
<!-- 降级分组选择仅当启用 claude_code_only 时显示 -->
<div v-if="createForm.claude_code_only" class="mt-3">
<label class="input-label">{{ t('admin.groups.claudeCode.fallbackGroup') }}</label>
<Select
v-model="createForm.fallback_group_id"
:options="fallbackGroupOptions"
:placeholder="t('admin.groups.claudeCode.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.claudeCode.fallbackHint') }}</p>
</div>
</div>
</form>
<template #footer>
@@ -648,6 +704,62 @@
</div>
</div>
<!-- Claude Code 客户端限制 anthropic 平台 -->
<div v-if="editForm.platform === 'anthropic'" class="border-t pt-4">
<div class="mb-1.5 flex items-center gap-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.groups.claudeCode.title') }}
</label>
<!-- Help Tooltip -->
<div class="group relative inline-flex">
<Icon
name="questionCircle"
size="sm"
:stroke-width="2"
class="cursor-help text-gray-400 transition-colors hover:text-primary-500 dark:text-gray-500 dark:hover:text-primary-400"
/>
<div class="pointer-events-none absolute bottom-full left-0 z-50 mb-2 w-72 opacity-0 transition-all duration-200 group-hover:pointer-events-auto group-hover:opacity-100">
<div class="rounded-lg bg-gray-900 p-3 text-white shadow-lg dark:bg-gray-800">
<p class="text-xs leading-relaxed text-gray-300">
{{ t('admin.groups.claudeCode.tooltip') }}
</p>
<div class="absolute -bottom-1.5 left-3 h-3 w-3 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="editForm.claude_code_only = !editForm.claude_code_only"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
editForm.claude_code_only ? 'bg-primary-500' : 'bg-gray-300 dark:bg-dark-600'
]"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
editForm.claude_code_only ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ editForm.claude_code_only ? t('admin.groups.claudeCode.enabled') : t('admin.groups.claudeCode.disabled') }}
</span>
</div>
<!-- 降级分组选择仅当启用 claude_code_only 时显示 -->
<div v-if="editForm.claude_code_only" class="mt-3">
<label class="input-label">{{ t('admin.groups.claudeCode.fallbackGroup') }}</label>
<Select
v-model="editForm.fallback_group_id"
:options="fallbackGroupOptionsForEdit"
:placeholder="t('admin.groups.claudeCode.noFallback')"
/>
<p class="input-hint">{{ t('admin.groups.claudeCode.fallbackHint') }}</p>
</div>
</div>
</form>
<template #footer>
@@ -774,6 +886,35 @@ const subscriptionTypeOptions = computed(() => [
{ value: 'subscription', label: t('admin.groups.subscription.subscription') }
])
// 降级分组选项(创建时)- 仅包含 anthropic 平台且未启用 claude_code_only 的分组
const fallbackGroupOptions = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.claudeCode.noFallback') }
]
const eligibleGroups = groups.value.filter(
(g) => g.platform === 'anthropic' && !g.claude_code_only && g.status === 'active'
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
// 降级分组选项(编辑时)- 排除自身
const fallbackGroupOptionsForEdit = computed(() => {
const options: { value: number | null; label: string }[] = [
{ value: null, label: t('admin.groups.claudeCode.noFallback') }
]
const currentId = editingGroup.value?.id
const eligibleGroups = groups.value.filter(
(g) => g.platform === 'anthropic' && !g.claude_code_only && g.status === 'active' && g.id !== currentId
)
eligibleGroups.forEach((g) => {
options.push({ value: g.id, label: g.name })
})
return options
})
const groups = ref<Group[]>([])
const loading = ref(false)
const searchQuery = ref('')
@@ -821,7 +962,10 @@ const createForm = reactive({
// 图片生成计费配置(仅 antigravity 平台使用)
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null
image_price_4k: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null
})
const editForm = reactive({
@@ -838,7 +982,10 @@ const editForm = reactive({
// 图片生成计费配置(仅 antigravity 平台使用)
image_price_1k: null as number | null,
image_price_2k: null as number | null,
image_price_4k: null as number | null
image_price_4k: null as number | null,
// Claude Code 客户端限制(仅 anthropic 平台使用)
claude_code_only: false,
fallback_group_id: null as number | null
})
// 根据分组类型返回不同的删除确认消息
@@ -908,6 +1055,8 @@ const closeCreateModal = () => {
createForm.image_price_1k = null
createForm.image_price_2k = null
createForm.image_price_4k = null
createForm.claude_code_only = false
createForm.fallback_group_id = null
}
const handleCreateGroup = async () => {
@@ -949,6 +1098,8 @@ const handleEdit = (group: Group) => {
editForm.image_price_1k = group.image_price_1k
editForm.image_price_2k = group.image_price_2k
editForm.image_price_4k = group.image_price_4k
editForm.claude_code_only = group.claude_code_only || false
editForm.fallback_group_id = group.fallback_group_id
showEditModal.value = true
}
@@ -966,7 +1117,12 @@ const handleUpdateGroup = async () => {
submitting.value = true
try {
await adminAPI.groups.update(editingGroup.value.id, editForm)
// 转换 fallback_group_id: null -> 0 (后端使用 0 表示清除)
const payload = {
...editForm,
fallback_group_id: editForm.fallback_group_id === null ? 0 : editForm.fallback_group_id
}
await adminAPI.groups.update(editingGroup.value.id, payload)
appStore.showSuccess(t('admin.groups.groupUpdated'))
closeEditModal()
loadGroups()