Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
67a05dfc
Commit
67a05dfc
authored
Apr 10, 2026
by
IanShaw027
Browse files
fix: honor table defaults and preserve dispatch mappings
parent
b6bc0423
Changes
9
Show whitespace changes
Inline
Side-by-side
backend/internal/service/api_key_auth_cache.go
View file @
67a05dfc
...
...
@@ -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"`
...
...
@@ -65,6 +66,7 @@ type APIKeyAuthGroupSnapshot struct {
// OpenAI Messages 调度配置(仅 openai 平台使用)
AllowMessagesDispatch
bool
`json:"allow_messages_dispatch"`
DefaultMappedModel
string
`json:"default_mapped_model,omitempty"`
MessagesDispatchModelConfig
OpenAIMessagesDispatchModelConfig
`json:"messages_dispatch_model_config,omitempty"`
}
// APIKeyAuthCacheEntry 缓存条目,支持负缓存
...
...
backend/internal/service/api_key_auth_cache_impl.go
View file @
67a05dfc
...
...
@@ -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
)
...
...
backend/internal/service/api_key_service_cache_test.go
View file @
67a05dfc
...
...
@@ -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
{
...
...
frontend/src/components/common/Pagination.vue
View file @
67a05dfc
...
...
@@ -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
)
}
...
...
frontend/src/composables/__tests__/usePersistedPageSize.spec.ts
0 → 100644
View file @
67a05dfc
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
)
})
})
frontend/src/composables/usePersistedPageSize.ts
View file @
67a05dfc
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
)
}
frontend/src/composables/useTableLoader.ts
View file @
67a05dfc
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<T, P extends Record<string, any>>(options: TableL
const
handlePageSizeChange
=
(
size
:
number
)
=>
{
pagination
.
page_size
=
size
pagination
.
page
=
1
setPersistedPageSize
(
size
)
load
()
}
...
...
frontend/src/stores/__tests__/app.spec.ts
View file @
67a05dfc
...
...
@@ -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
()
})
})
})
frontend/src/stores/app.ts
View file @
67a05dfc
...
...
@@ -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
||
''
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment