diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index ad6ba0e9..c2e96df1 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -4,6 +4,7 @@ import "time" // APIKeyAuthSnapshot API Key 认证缓存快照(仅包含认证所需字段) type APIKeyAuthSnapshot struct { + Version int `json:"version"` APIKeyID int64 `json:"api_key_id"` UserID int64 `json:"user_id"` GroupID *int64 `json:"group_id,omitempty"` @@ -63,8 +64,9 @@ type APIKeyAuthGroupSnapshot struct { SupportedModelScopes []string `json:"supported_model_scopes,omitempty"` // OpenAI Messages 调度配置(仅 openai 平台使用) - AllowMessagesDispatch bool `json:"allow_messages_dispatch"` - DefaultMappedModel string `json:"default_mapped_model,omitempty"` + AllowMessagesDispatch bool `json:"allow_messages_dispatch"` + DefaultMappedModel string `json:"default_mapped_model,omitempty"` + MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` } // APIKeyAuthCacheEntry 缓存条目,支持负缓存 diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 64a70e8c..8069ed4f 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -13,6 +13,8 @@ import ( "github.com/dgraph-io/ristretto" ) +const apiKeyAuthSnapshotVersion = 3 + type apiKeyAuthCacheConfig struct { l1Size int l1TTL time.Duration @@ -192,6 +194,9 @@ func (s *APIKeyService) applyAuthCacheEntry(key string, entry *APIKeyAuthCacheEn if entry.Snapshot == nil { return nil, false, nil } + if entry.Snapshot.Version != apiKeyAuthSnapshotVersion { + return nil, false, nil + } return s.snapshotToAPIKey(key, entry.Snapshot), true, nil } @@ -200,6 +205,7 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot { return nil } snapshot := &APIKeyAuthSnapshot{ + Version: apiKeyAuthSnapshotVersion, APIKeyID: apiKey.ID, UserID: apiKey.UserID, GroupID: apiKey.GroupID, @@ -243,6 +249,7 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot { SupportedModelScopes: apiKey.Group.SupportedModelScopes, AllowMessagesDispatch: apiKey.Group.AllowMessagesDispatch, DefaultMappedModel: apiKey.Group.DefaultMappedModel, + MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, } } return snapshot @@ -298,6 +305,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho SupportedModelScopes: snapshot.Group.SupportedModelScopes, AllowMessagesDispatch: snapshot.Group.AllowMessagesDispatch, DefaultMappedModel: snapshot.Group.DefaultMappedModel, + MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig, } } s.compileAPIKeyIPRules(apiKey) diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index 357f8def..3c2f7dbb 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -188,6 +188,7 @@ func TestAPIKeyService_GetByKey_UsesL2Cache(t *testing.T) { groupID := int64(9) cacheEntry := &APIKeyAuthCacheEntry{ Snapshot: &APIKeyAuthSnapshot{ + Version: apiKeyAuthSnapshotVersion, APIKeyID: 1, UserID: 2, GroupID: &groupID, @@ -226,6 +227,129 @@ func TestAPIKeyService_GetByKey_UsesL2Cache(t *testing.T) { require.Equal(t, map[string][]int64{"claude-opus-*": {1, 2}}, apiKey.Group.ModelRouting) } +func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t *testing.T) { + svc := NewAPIKeyService(nil, nil, nil, nil, nil, nil, &config.Config{}) + groupID := int64(9) + apiKey := &APIKey{ + ID: 1, + UserID: 2, + GroupID: &groupID, + Key: "k-roundtrip", + Status: StatusActive, + User: &User{ + ID: 2, + Status: StatusActive, + Role: RoleUser, + Balance: 10, + Concurrency: 3, + }, + Group: &Group{ + ID: groupID, + Name: "openai", + Platform: PlatformOpenAI, + Status: StatusActive, + SubscriptionType: SubscriptionTypeStandard, + RateMultiplier: 1, + AllowMessagesDispatch: true, + DefaultMappedModel: "gpt-5.4", + MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{ + OpusMappedModel: "gpt-5.4-nano", + SonnetMappedModel: "gpt-5.3-codex", + HaikuMappedModel: "gpt-5.4-mini", + ExactModelMappings: map[string]string{ + "claude-sonnet-4.5": "gpt-5.4-nano", + }, + }, + }, + } + + snapshot := svc.snapshotFromAPIKey(apiKey) + roundTrip := svc.snapshotToAPIKey(apiKey.Key, snapshot) + + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.Group) + require.Equal(t, apiKey.Group.MessagesDispatchModelConfig, roundTrip.Group.MessagesDispatchModelConfig) +} + +func TestAPIKeyService_GetByKey_IgnoresLegacyAuthCacheSnapshotWithoutMessagesDispatchConfig(t *testing.T) { + cache := &authCacheStub{} + var repoCalls int32 + repo := &authRepoStub{ + getByKeyForAuth: func(ctx context.Context, key string) (*APIKey, error) { + atomic.AddInt32(&repoCalls, 1) + groupID := int64(9) + return &APIKey{ + ID: 1, + UserID: 2, + GroupID: &groupID, + Status: StatusActive, + User: &User{ + ID: 2, + Status: StatusActive, + Role: RoleUser, + Balance: 10, + Concurrency: 3, + }, + Group: &Group{ + ID: groupID, + Name: "openai", + Platform: PlatformOpenAI, + Status: StatusActive, + Hydrated: true, + SubscriptionType: SubscriptionTypeStandard, + RateMultiplier: 1, + AllowMessagesDispatch: true, + DefaultMappedModel: "gpt-5.4", + MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{ + OpusMappedModel: "gpt-5.4-nano", + }, + }, + }, nil + }, + } + cfg := &config.Config{ + APIKeyAuth: config.APIKeyAuthCacheConfig{ + L2TTLSeconds: 60, + }, + } + svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg) + + groupID := int64(9) + cache.getAuthCache = func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) { + return &APIKeyAuthCacheEntry{ + Snapshot: &APIKeyAuthSnapshot{ + APIKeyID: 1, + UserID: 2, + GroupID: &groupID, + Status: StatusActive, + User: APIKeyAuthUserSnapshot{ + ID: 2, + Status: StatusActive, + Role: RoleUser, + Balance: 10, + Concurrency: 3, + }, + Group: &APIKeyAuthGroupSnapshot{ + ID: groupID, + Name: "openai", + Platform: PlatformOpenAI, + Status: StatusActive, + SubscriptionType: SubscriptionTypeStandard, + RateMultiplier: 1, + AllowMessagesDispatch: true, + DefaultMappedModel: "gpt-5.4", + }, + }, + }, nil + } + + apiKey, err := svc.GetByKey(context.Background(), "k-legacy") + require.NoError(t, err) + require.Equal(t, int32(1), atomic.LoadInt32(&repoCalls)) + require.NotNil(t, apiKey.Group) + require.Equal(t, "gpt-5.4-nano", apiKey.Group.MessagesDispatchModelConfig.OpusMappedModel) +} + func TestAPIKeyService_GetByKey_NegativeCache(t *testing.T) { cache := &authCacheStub{} repo := &authRepoStub{ diff --git a/frontend/src/components/common/Pagination.vue b/frontend/src/components/common/Pagination.vue index f7f69aaa..2bfc6872 100644 --- a/frontend/src/components/common/Pagination.vue +++ b/frontend/src/components/common/Pagination.vue @@ -122,7 +122,6 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import Icon from '@/components/icons/Icon.vue' import Select from './Select.vue' -import { setPersistedPageSize } from '@/composables/usePersistedPageSize' import { getConfiguredTablePageSizeOptions, normalizeTablePageSize } from '@/utils/tablePreferences' const { t } = useI18n() @@ -225,7 +224,6 @@ const goToPage = (newPage: number) => { const handlePageSizeChange = (value: string | number | boolean | null) => { if (value === null || typeof value === 'boolean') return const newPageSize = normalizeTablePageSize(typeof value === 'string' ? parseInt(value, 10) : value) - setPersistedPageSize(newPageSize) emit('update:pageSize', newPageSize) } diff --git a/frontend/src/composables/__tests__/usePersistedPageSize.spec.ts b/frontend/src/composables/__tests__/usePersistedPageSize.spec.ts new file mode 100644 index 00000000..b06fe1a1 --- /dev/null +++ b/frontend/src/composables/__tests__/usePersistedPageSize.spec.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { getPersistedPageSize } from '@/composables/usePersistedPageSize' + +describe('usePersistedPageSize', () => { + afterEach(() => { + localStorage.clear() + delete window.__APP_CONFIG__ + }) + + it('uses the system table default instead of stale localStorage state', () => { + window.__APP_CONFIG__ = { + table_default_page_size: 1000, + table_page_size_options: [20, 50, 1000] + } as any + localStorage.setItem('table-page-size', '50') + localStorage.setItem('table-page-size-source', 'user') + + expect(getPersistedPageSize()).toBe(1000) + }) +}) diff --git a/frontend/src/composables/usePersistedPageSize.ts b/frontend/src/composables/usePersistedPageSize.ts index c2bfae42..366619ea 100644 --- a/frontend/src/composables/usePersistedPageSize.ts +++ b/frontend/src/composables/usePersistedPageSize.ts @@ -1,49 +1,9 @@ import { getConfiguredTableDefaultPageSize, normalizeTablePageSize } from '@/utils/tablePreferences' -const STORAGE_KEY = 'table-page-size' -const SOURCE_KEY = 'table-page-size-source' - /** - * 从 localStorage 读取/写入 pageSize - * 全局共享一个 key,所有表格统一偏好 + * 读取当前系统配置的表格默认每页条数。 + * 不再使用本地持久化缓存,所有页面统一以通用表格设置为准。 */ export function getPersistedPageSize(fallback = getConfiguredTableDefaultPageSize()): number { - try { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored) { - return normalizeTablePageSize(stored) - } - } catch { - // localStorage 不可用(隐私模式等) - } - return normalizeTablePageSize(fallback) -} - -export function setPersistedPageSize(size: number): void { - try { - localStorage.setItem(STORAGE_KEY, String(normalizeTablePageSize(size))) - localStorage.setItem(SOURCE_KEY, 'user') - } catch { - // 静默失败 - } -} - -export function syncPersistedPageSizeWithSystemDefault(defaultSize = getConfiguredTableDefaultPageSize()): void { - try { - const normalizedDefault = normalizeTablePageSize(defaultSize) - const stored = localStorage.getItem(STORAGE_KEY) - const source = localStorage.getItem(SOURCE_KEY) - const normalizedStored = stored ? normalizeTablePageSize(stored) : null - - if ((source === 'user' || (source === null && stored !== null)) && stored) { - localStorage.setItem(STORAGE_KEY, String(normalizedStored ?? normalizedDefault)) - localStorage.setItem(SOURCE_KEY, 'user') - return - } - - localStorage.setItem(STORAGE_KEY, String(normalizedDefault)) - localStorage.setItem(SOURCE_KEY, 'system') - } catch { - // 静默失败 - } + return normalizeTablePageSize(getConfiguredTableDefaultPageSize() || fallback) } diff --git a/frontend/src/composables/useTableLoader.ts b/frontend/src/composables/useTableLoader.ts index 67c1dcdb..c288f42e 100644 --- a/frontend/src/composables/useTableLoader.ts +++ b/frontend/src/composables/useTableLoader.ts @@ -1,7 +1,7 @@ import { ref, reactive, onUnmounted, toRaw } from 'vue' import { useDebounceFn } from '@vueuse/core' import type { BasePaginationResponse, FetchOptions } from '@/types' -import { getPersistedPageSize, setPersistedPageSize } from './usePersistedPageSize' +import { getPersistedPageSize } from './usePersistedPageSize' interface PaginationState { page: number @@ -88,7 +88,6 @@ export function useTableLoader>(options: TableL const handlePageSizeChange = (size: number) => { pagination.page_size = size pagination.page = 1 - setPersistedPageSize(size) load() } diff --git a/frontend/src/stores/__tests__/app.spec.ts b/frontend/src/stores/__tests__/app.spec.ts index efc7f1bf..7dbe6bc3 100644 --- a/frontend/src/stores/__tests__/app.spec.ts +++ b/frontend/src/stores/__tests__/app.spec.ts @@ -329,85 +329,8 @@ describe('useAppStore', () => { expect((window as any).__APP_CONFIG__.table_default_page_size).toBe(1000) expect((window as any).__APP_CONFIG__.table_page_size_options).toEqual([20, 100, 1000]) - expect(localStorage.getItem('table-page-size')).toBe('1000') - expect(localStorage.getItem('table-page-size-source')).toBe('system') - }) - - it('fetchPublicSettings(force) 保留用户显式选择的分页大小', async () => { - localStorage.setItem('table-page-size', '100') - localStorage.setItem('table-page-size-source', 'user') - - vi.mocked(getPublicSettings).mockResolvedValue({ - registration_enabled: false, - email_verify_enabled: false, - registration_email_suffix_whitelist: [], - promo_code_enabled: true, - password_reset_enabled: false, - invitation_code_enabled: false, - turnstile_enabled: false, - turnstile_site_key: '', - site_name: 'Updated Site', - site_logo: '', - site_subtitle: '', - api_base_url: '', - contact_info: '', - doc_url: '', - home_content: '', - hide_ccs_import_button: false, - purchase_subscription_enabled: false, - purchase_subscription_url: '', - table_default_page_size: 1000, - table_page_size_options: [20, 50, 1000], - custom_menu_items: [], - custom_endpoints: [], - linuxdo_oauth_enabled: false, - backend_mode_enabled: false, - version: '1.0.0' - }) - - const store = useAppStore() - await store.fetchPublicSettings(true) - - expect(localStorage.getItem('table-page-size')).toBe('1000') - expect(localStorage.getItem('table-page-size-source')).toBe('user') - }) - - it('fetchPublicSettings(force) 保留旧版本未标记来源的分页偏好', async () => { - localStorage.setItem('table-page-size', '50') - - vi.mocked(getPublicSettings).mockResolvedValue({ - registration_enabled: false, - email_verify_enabled: false, - registration_email_suffix_whitelist: [], - promo_code_enabled: true, - password_reset_enabled: false, - invitation_code_enabled: false, - turnstile_enabled: false, - turnstile_site_key: '', - site_name: 'Updated Site', - site_logo: '', - site_subtitle: '', - api_base_url: '', - contact_info: '', - doc_url: '', - home_content: '', - hide_ccs_import_button: false, - purchase_subscription_enabled: false, - purchase_subscription_url: '', - table_default_page_size: 1000, - table_page_size_options: [20, 50, 1000], - custom_menu_items: [], - custom_endpoints: [], - linuxdo_oauth_enabled: false, - backend_mode_enabled: false, - version: '1.0.0' - }) - - const store = useAppStore() - await store.fetchPublicSettings(true) - - expect(localStorage.getItem('table-page-size')).toBe('50') - expect(localStorage.getItem('table-page-size-source')).toBe('user') + expect(localStorage.getItem('table-page-size')).toBeNull() + expect(localStorage.getItem('table-page-size-source')).toBeNull() }) }) }) diff --git a/frontend/src/stores/app.ts b/frontend/src/stores/app.ts index a8691ffa..8a004a4a 100644 --- a/frontend/src/stores/app.ts +++ b/frontend/src/stores/app.ts @@ -12,7 +12,6 @@ import { type ReleaseInfo } from '@/api/admin/system' import { getPublicSettings as fetchPublicSettingsAPI } from '@/api/auth' -import { syncPersistedPageSizeWithSystemDefault } from '@/composables/usePersistedPageSize' export const useAppStore = defineStore('app', () => { // ==================== State ==================== @@ -288,7 +287,6 @@ export const useAppStore = defineStore('app', () => { if (typeof window !== 'undefined') { window.__APP_CONFIG__ = { ...config } } - syncPersistedPageSizeWithSystemDefault(config.table_default_page_size) cachedPublicSettings.value = config siteName.value = config.site_name || 'Sub2API' siteLogo.value = config.site_logo || ''