mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4908,6 +4908,7 @@ export default {
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
grease: 'GREASE',
|
||||
boundAccounts: 'In use',
|
||||
alpn: 'ALPN',
|
||||
actions: 'Actions'
|
||||
},
|
||||
|
||||
@@ -5079,6 +5079,7 @@ export default {
|
||||
name: '名称',
|
||||
description: '描述',
|
||||
grease: 'GREASE',
|
||||
boundAccounts: '使用中',
|
||||
alpn: 'ALPN',
|
||||
actions: '操作'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user