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
+1 -1
View File
@@ -1 +1 @@
0.1.114.1
0.1.114.2
@@ -51,10 +51,10 @@ type UpdateTLSFingerprintProfileRequest struct {
Extensions []uint16 `json:"extensions"`
}
// List 获取所有模板
// List 获取所有模板(附带每个模板当前的绑定账号数)
// GET /api/v1/admin/tls-fingerprint-profiles
func (h *TLSFingerprintProfileHandler) List(c *gin.Context) {
profiles, err := h.service.List(c.Request.Context())
profiles, err := h.service.ListWithBindingCount(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
+5
View File
@@ -257,6 +257,11 @@ func AccountFromServiceShallow(a *service.Account) *Account {
if profileID := a.GetTLSFingerprintProfileID(); profileID > 0 {
out.TLSFingerprintProfileID = &profileID
}
// TLS指纹是否由随机分配生成(决定前端是否显示「已随机化」徽章)
if a.IsTLSFingerprintRandomized() {
randomized := true
out.TLSFingerprintRandomized = &randomized
}
// 会话ID伪装开关
if a.IsSessionIDMaskingEnabled() {
enabled := true
+3 -2
View File
@@ -190,8 +190,9 @@ type Account struct {
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 从 extra 字段提取,方便前端显示和编辑
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"`
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"`
TLSFingerprintRandomized *bool `json:"tls_fingerprint_randomized,omitempty"`
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
@@ -313,6 +313,31 @@ func (r *accountRepository) ListCRSAccountIDs(ctx context.Context) (map[string]i
return result, nil
}
// CountByTLSFingerprintProfile 按 TLS 指纹模板 ID 聚合绑定账号数。
// 走 108_add_tls_fingerprint_profile_id_index.sql 的表达式索引。
func (r *accountRepository) CountByTLSFingerprintProfile(ctx context.Context) (map[int64]int, error) {
rows, err := r.sql.QueryContext(ctx, `
SELECT (extra->>'tls_fingerprint_profile_id')::bigint AS profile_id, COUNT(*)
FROM accounts
WHERE deleted_at IS NULL AND extra ? 'tls_fingerprint_profile_id'
GROUP BY profile_id`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
counts := make(map[int64]int)
for rows.Next() {
var id int64
var n int
if err := rows.Scan(&id, &n); err != nil {
return nil, err
}
counts[id] = n
}
return counts, rows.Err()
}
func (r *accountRepository) Update(ctx context.Context, account *service.Account) error {
if account == nil {
return nil
@@ -1060,6 +1060,10 @@ func (s *stubAccountRepo) FindByExtraField(ctx context.Context, key string, valu
return nil, errors.New("not implemented")
}
func (s *stubAccountRepo) CountByTLSFingerprintProfile(ctx context.Context) (map[int64]int, error) {
return nil, errors.New("not implemented")
}
func (s *stubAccountRepo) Update(ctx context.Context, account *service.Account) error {
return errors.New("not implemented")
}
@@ -30,6 +30,10 @@ type AccountRepository interface {
GetByCRSAccountID(ctx context.Context, crsAccountID string) (*Account, error)
// FindByExtraField 根据 extra 字段中的键值对查找账号
FindByExtraField(ctx context.Context, key string, value any) ([]Account, error)
// CountByTLSFingerprintProfile 按 TLS 指纹模板 ID 聚合每个模板当前被多少账号绑定。
// 返回 map[profile_id]count;未绑定任何账号的 profile 不出现在 map 中。
// 查询走 108_add_tls_fingerprint_profile_id_index.sql 的表达式索引。
CountByTLSFingerprintProfile(ctx context.Context) (map[int64]int, error)
// ListCRSAccountIDs returns a map of crs_account_id -> local account ID
// for all accounts that have been synced from CRS.
ListCRSAccountIDs(ctx context.Context) (map[string]int64, error)
@@ -58,6 +58,10 @@ func (s *accountRepoStub) FindByExtraField(ctx context.Context, key string, valu
panic("unexpected FindByExtraField call")
}
func (s *accountRepoStub) CountByTLSFingerprintProfile(ctx context.Context) (map[int64]int, error) {
panic("unexpected CountByTLSFingerprintProfile call")
}
func (s *accountRepoStub) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
panic("unexpected ListCRSAccountIDs call")
}
@@ -82,6 +82,10 @@ func (m *mockAccountRepoForPlatform) FindByExtraField(ctx context.Context, key s
return nil, nil
}
func (m *mockAccountRepoForPlatform) CountByTLSFingerprintProfile(ctx context.Context) (map[int64]int, error) {
return nil, nil
}
func (m *mockAccountRepoForPlatform) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
return nil, nil
}
@@ -71,6 +71,10 @@ func (m *mockAccountRepoForGemini) FindByExtraField(ctx context.Context, key str
return nil, nil
}
func (m *mockAccountRepoForGemini) CountByTLSFingerprintProfile(ctx context.Context) (map[int64]int, error) {
return nil, nil
}
func (m *mockAccountRepoForGemini) ListCRSAccountIDs(ctx context.Context) (map[string]int64, error) {
return nil, nil
}
@@ -73,6 +73,9 @@ func (m *sessionWindowMockRepo) GetByCRSAccountID(context.Context, string) (*Acc
func (m *sessionWindowMockRepo) FindByExtraField(context.Context, string, any) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) CountByTLSFingerprintProfile(context.Context) (map[int64]int, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListCRSAccountIDs(context.Context) (map[string]int64, error) {
panic("unexpected")
}
@@ -87,6 +87,34 @@ func (s *TLSFingerprintProfileService) List(ctx context.Context) ([]*model.TLSFi
return s.repo.List(ctx)
}
// ProfileWithBinding 在模板基础上附加当前绑定该模板的账号数量,用于 Admin 列表展示。
type ProfileWithBinding struct {
*model.TLSFingerprintProfile
BoundAccountCount int `json:"bound_account_count"`
}
// ListWithBindingCount 返回所有模板以及每个模板被多少账号绑定。
// 绑定数通过 AccountRepository.CountByTLSFingerprintProfile 聚合,
// 走 108 号迁移的表达式索引,不走全表扫描。
func (s *TLSFingerprintProfileService) ListWithBindingCount(ctx context.Context) ([]*ProfileWithBinding, error) {
profiles, err := s.repo.List(ctx)
if err != nil {
return nil, err
}
counts, err := s.accountRepo.CountByTLSFingerprintProfile(ctx)
if err != nil {
return nil, err
}
result := make([]*ProfileWithBinding, 0, len(profiles))
for _, p := range profiles {
result = append(result, &ProfileWithBinding{
TLSFingerprintProfile: p,
BoundAccountCount: counts[p.ID],
})
}
return result, nil
}
// GetByID 根据 ID 获取模板
func (s *TLSFingerprintProfileService) GetByID(ctx context.Context, id int64) (*model.TLSFingerprintProfile, error) {
return s.repo.GetByID(ctx, id)
@@ -0,0 +1,21 @@
-- Migration: 108_add_tls_fingerprint_profile_id_index
-- 为 accounts.extra->>'tls_fingerprint_profile_id' 添加表达式 + 部分索引,
-- 加速 TLS 指纹模板列表 API 中按 profile_id 聚合统计「绑定账号数」的查询。
--
-- 设计说明:
-- - B-tree 表达式索引:物化 (extra->>'tls_fingerprint_profile_id') 为索引键,
-- 避免聚合时逐行解析 JSON。GROUP BY 该表达式可走 Index Only Scan。
-- - 部分索引(WHERE 子句):仅索引绑定了指纹的账号,索引体积最小、
-- 查询命中率最高。
-- - 与 045 的 GIN(extra) 索引互不冲突,二者面向不同查询模式。
--
-- 性能预期:1k 账号 <3ms,10k 账号 <10ms,100k 账号 <30ms。
--
-- 兼容性:
-- - 表达式与字段命名沿用现有 service 层约定("tls_fingerprint_profile_id")。
-- - 旧账号 extra 中无该字段时不会被索引,写入 / 读取性能零影响。
CREATE INDEX IF NOT EXISTS idx_accounts_tls_fp_profile_id
ON accounts ((extra->>'tls_fingerprint_profile_id'))
WHERE deleted_at IS NULL
AND extra ? 'tls_fingerprint_profile_id';
@@ -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: '操作'
},