feat(tls-fingerprint): show binding count + fix randomized fingerprint visibility (v0.1.114.2)

## Bug fixes
- EditAccountModal: auto-generated profiles (__auto__:acc-*) are no longer
  hidden from the dropdown; accounts bound to their own auto profile can
  now see and keep the selection.
- AccountResponse DTO: emit tls_fingerprint_randomized so the "randomized"
  badge and reshuffle affordance render on subsequent edits.

## Feature
- TLS fingerprint profile list returns bound_account_count per profile,
  aggregated via a single grouped SQL query over accounts.extra.
- Dropdowns and admin management table surface the binding count so admins
  can judge whether a fingerprint is shared before editing or deleting it.

## Performance
- Migration 108 adds a partial + expression index on
  (extra->>'tls_fingerprint_profile_id') WHERE extra ? '...',
  enabling Index Only Scan + HashAggregate for the new query.
- Extraction uses (extra->>'...')::bigint so PostgreSQL performs the cast;
  Go-side parsing and NullString plumbing are removed.

## Backend
- AccountRepository: add CountByTLSFingerprintProfile; implement in ent
  repo via grouped raw SQL.
- TLSFingerprintProfileService: add ProfileWithBinding + ListWithBindingCount.
- Admin handler List now returns the enriched payload.
- AccountResponse DTO: add tls_fingerprint_randomized (optional, omitempty).
- Update all five AccountRepository test stubs for the new interface method
  (account_service_delete_test, gateway_multiplatform_test,
  gemini_multiplatform_test, ratelimit_session_window_test,
  server/api_contract_test).

## Frontend
- TLSFingerprintProfile type: add optional bound_account_count.
- EditAccountModal + CreateAccountModal: show " (N)" suffix on options
  with active bindings; drop the auto-profile filter.
- TLSFingerprintProfilesModal admin table: new "使用中 / In use" column
  with amber-highlighted count.
- i18n zh/en: add columns.boundAccounts.

## Compatibility
- New fields are all optional (omitempty) — existing OAuth accounts and
  older frontends behave as before.
- No data migration required; empty extra entries are ignored by index
  and aggregation alike.
This commit is contained in:
erio
2026-04-18 21:45:17 +08:00
parent 6d0e056244
commit cfd9566905
19 changed files with 144 additions and 15 deletions
@@ -24,6 +24,8 @@ export interface TLSFingerprintProfile {
extensions: number[]
created_at: string
updated_at: string
// 当前被多少账号绑定;仅 List 接口返回,GetByID/Create/Update 可能缺省
bound_account_count?: number
}
/**
@@ -2169,7 +2169,9 @@
<select v-model="tlsFingerprintProfileId" class="input">
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">
{{ p.name }}{{ p.bound_account_count ? ` (${p.bound_account_count})` : '' }}
</option>
</select>
<!-- Auto-randomize on create (Anthropic OAuth/setup-token only) -->
<label
@@ -3173,7 +3175,7 @@ const umqModeOptions = computed(() => [
])
const tlsFingerprintEnabled = ref(false)
const tlsFingerprintProfileId = ref<number | null>(null)
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
const tlsFingerprintProfiles = ref<{ id: number; name: string; bound_account_count?: number }[]>([])
const tlsFingerprintRandomizeOnCreate = ref(false)
const sessionIdMaskingEnabled = ref(false)
const cacheTTLOverrideEnabled = ref(false)
@@ -3343,7 +3345,13 @@ watch(
if (newVal) {
// Load TLS fingerprint profiles
adminAPI.tlsFingerprintProfiles.list()
.then(profiles => { tlsFingerprintProfiles.value = profiles.map(p => ({ id: p.id, name: p.name })) })
.then(profiles => {
tlsFingerprintProfiles.value = profiles.map(p => ({
id: p.id,
name: p.name,
bound_account_count: p.bound_account_count,
}))
})
.catch(() => { tlsFingerprintProfiles.value = [] })
// Modal opened - fill related models
allowedModels.value = [...getModelsByPlatform(form.platform)]
@@ -1611,7 +1611,9 @@
>
<option :value="null">{{ t('admin.accounts.quotaControl.tlsFingerprint.defaultProfile') }}</option>
<option v-if="tlsFingerprintProfiles.length > 0" :value="-1">{{ t('admin.accounts.quotaControl.tlsFingerprint.randomProfile') }}</option>
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">{{ p.name }}</option>
<option v-for="p in tlsFingerprintProfiles" :key="p.id" :value="p.id">
{{ p.name }}{{ p.bound_account_count ? ` (${p.bound_account_count})` : '' }}
</option>
</select>
<span
v-if="tlsFingerprintRandomized"
@@ -2024,7 +2026,7 @@ const umqModeOptions = computed(() => [
])
const tlsFingerprintEnabled = ref(false)
const tlsFingerprintProfileId = ref<number | null>(null)
const tlsFingerprintProfiles = ref<{ id: number; name: string }[]>([])
const tlsFingerprintProfiles = ref<{ id: number; name: string; bound_account_count?: number }[]>([])
const tlsFingerprintRandomized = ref(false)
const tlsFingerprintRandomizing = ref(false)
const sessionIdMaskingEnabled = ref(false)
@@ -2483,11 +2485,11 @@ watch(
const loadTLSProfiles = async () => {
try {
const profiles = await adminAPI.tlsFingerprintProfiles.list()
// Hide auto-generated profiles from the manual dropdown; they're
// owned by a specific account and shouldn't be picked by others.
tlsFingerprintProfiles.value = profiles
.filter(p => !p.name.startsWith('__auto__:acc-'))
.map(p => ({ id: p.id, name: p.name }))
tlsFingerprintProfiles.value = profiles.map(p => ({
id: p.id,
name: p.name,
bound_account_count: p.bound_account_count,
}))
} catch {
tlsFingerprintProfiles.value = []
}
@@ -47,6 +47,9 @@
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.tlsFingerprintProfiles.columns.grease') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.tlsFingerprintProfiles.columns.boundAccounts') }}
</th>
<th class="px-3 py-2 text-left text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
{{ t('admin.tlsFingerprintProfiles.columns.alpn') }}
</th>
@@ -73,6 +76,15 @@
:class="profile.enable_grease ? 'text-green-500' : 'text-gray-400'"
/>
</td>
<td class="px-3 py-2">
<span
v-if="profile.bound_account_count"
class="text-sm font-semibold text-amber-600 dark:text-amber-400"
>
{{ profile.bound_account_count }}
</span>
<span v-else class="text-xs text-gray-400 dark:text-gray-600">0</span>
</td>
<td class="px-3 py-2">
<div v-if="profile.alpn_protocols?.length" class="flex flex-wrap gap-1">
<span
+1
View File
@@ -4908,6 +4908,7 @@ export default {
name: 'Name',
description: 'Description',
grease: 'GREASE',
boundAccounts: 'In use',
alpn: 'ALPN',
actions: 'Actions'
},
+1
View File
@@ -5079,6 +5079,7 @@ export default {
name: '名称',
description: '描述',
grease: 'GREASE',
boundAccounts: '使用中',
alpn: 'ALPN',
actions: '操作'
},