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
52ad7c6e
Commit
52ad7c6e
authored
Jan 15, 2026
by
yangjianbo
Browse files
Merge branch 'main' into dev
parents
daf10907
b3b2868f
Changes
8
Hide whitespace changes
Inline
Side-by-side
backend/internal/repository/ops_repo_preagg.go
View file @
52ad7c6e
...
...
@@ -71,7 +71,9 @@ usage_agg AS (
error_base AS (
SELECT
date_trunc('hour', created_at AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' AS bucket_start,
platform AS platform,
-- platform is NULL for some early-phase errors (e.g. before routing); map to a sentinel
-- value so platform-level GROUPING SETS don't collide with the overall (platform=NULL) row.
COALESCE(platform, 'unknown') AS platform,
group_id AS group_id,
is_business_limited AS is_business_limited,
error_owner AS error_owner,
...
...
backend/internal/service/gateway_multiplatform_test.go
View file @
52ad7c6e
...
...
@@ -1211,6 +1211,72 @@ func TestGatewayService_SelectAccountWithLoadAwareness(t *testing.T) {
require
.
Nil
(
t
,
result
)
require
.
Contains
(
t
,
err
.
Error
(),
"no available accounts"
)
})
t
.
Run
(
"过滤不可调度账号-限流账号被跳过"
,
func
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
resetAt
:=
now
.
Add
(
10
*
time
.
Minute
)
repo
:=
&
mockAccountRepoForPlatform
{
accounts
:
[]
Account
{
{
ID
:
1
,
Platform
:
PlatformAnthropic
,
Priority
:
1
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
5
,
RateLimitResetAt
:
&
resetAt
},
{
ID
:
2
,
Platform
:
PlatformAnthropic
,
Priority
:
2
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
5
},
},
accountsByID
:
map
[
int64
]
*
Account
{},
}
for
i
:=
range
repo
.
accounts
{
repo
.
accountsByID
[
repo
.
accounts
[
i
]
.
ID
]
=
&
repo
.
accounts
[
i
]
}
cache
:=
&
mockGatewayCacheForPlatform
{}
cfg
:=
testConfig
()
cfg
.
Gateway
.
Scheduling
.
LoadBatchEnabled
=
false
svc
:=
&
GatewayService
{
accountRepo
:
repo
,
cache
:
cache
,
cfg
:
cfg
,
concurrencyService
:
nil
,
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
Equal
(
t
,
int64
(
2
),
result
.
Account
.
ID
,
"应跳过限流账号,选择可用账号"
)
})
t
.
Run
(
"过滤不可调度账号-过载账号被跳过"
,
func
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
overloadUntil
:=
now
.
Add
(
10
*
time
.
Minute
)
repo
:=
&
mockAccountRepoForPlatform
{
accounts
:
[]
Account
{
{
ID
:
1
,
Platform
:
PlatformAnthropic
,
Priority
:
1
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
5
,
OverloadUntil
:
&
overloadUntil
},
{
ID
:
2
,
Platform
:
PlatformAnthropic
,
Priority
:
2
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
5
},
},
accountsByID
:
map
[
int64
]
*
Account
{},
}
for
i
:=
range
repo
.
accounts
{
repo
.
accountsByID
[
repo
.
accounts
[
i
]
.
ID
]
=
&
repo
.
accounts
[
i
]
}
cache
:=
&
mockGatewayCacheForPlatform
{}
cfg
:=
testConfig
()
cfg
.
Gateway
.
Scheduling
.
LoadBatchEnabled
=
false
svc
:=
&
GatewayService
{
accountRepo
:
repo
,
cache
:
cache
,
cfg
:
cfg
,
concurrencyService
:
nil
,
}
result
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
ctx
,
nil
,
""
,
"claude-3-5-sonnet-20241022"
,
nil
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
result
)
require
.
NotNil
(
t
,
result
.
Account
)
require
.
Equal
(
t
,
int64
(
2
),
result
.
Account
.
ID
,
"应跳过过载账号,选择可用账号"
)
})
}
func
TestGatewayService_GroupResolution_ReusesContextGroup
(
t
*
testing
.
T
)
{
...
...
backend/internal/service/gateway_service.go
View file @
52ad7c6e
...
...
@@ -511,6 +511,12 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
if
isExcluded
(
acc
.
ID
)
{
continue
}
// Scheduler snapshots can be temporarily stale (bucket rebuild is throttled);
// re-check schedulability here so recently rate-limited/overloaded accounts
// are not selected again before the bucket is rebuilt.
if
!
acc
.
IsSchedulable
()
{
continue
}
if
!
s
.
isAccountAllowedForPlatform
(
acc
,
platform
,
useMixed
)
{
continue
}
...
...
@@ -893,6 +899,11 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
if
_
,
excluded
:=
excludedIDs
[
acc
.
ID
];
excluded
{
continue
}
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
// avoid selecting accounts that were recently rate-limited/overloaded.
if
!
acc
.
IsSchedulable
()
{
continue
}
if
!
acc
.
IsSchedulableForModel
(
requestedModel
)
{
continue
}
...
...
@@ -977,6 +988,11 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
if
_
,
excluded
:=
excludedIDs
[
acc
.
ID
];
excluded
{
continue
}
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
// avoid selecting accounts that were recently rate-limited/overloaded.
if
!
acc
.
IsSchedulable
()
{
continue
}
// 过滤:原生平台直接通过,antigravity 需要启用混合调度
if
acc
.
Platform
==
PlatformAntigravity
&&
!
acc
.
IsMixedSchedulingEnabled
()
{
continue
...
...
backend/internal/service/openai_gateway_service.go
View file @
52ad7c6e
...
...
@@ -186,6 +186,11 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C
if
_
,
excluded
:=
excludedIDs
[
acc
.
ID
];
excluded
{
continue
}
// Scheduler snapshots can be temporarily stale; re-check schedulability here to
// avoid selecting accounts that were recently rate-limited/overloaded.
if
!
acc
.
IsSchedulable
()
{
continue
}
// Check model support
if
requestedModel
!=
""
&&
!
acc
.
IsModelSupported
(
requestedModel
)
{
continue
...
...
@@ -332,6 +337,12 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
if
isExcluded
(
acc
.
ID
)
{
continue
}
// Scheduler snapshots can be temporarily stale (bucket rebuild is throttled);
// re-check schedulability here so recently rate-limited/overloaded accounts
// are not selected again before the bucket is rebuilt.
if
!
acc
.
IsSchedulable
()
{
continue
}
if
requestedModel
!=
""
&&
!
acc
.
IsModelSupported
(
requestedModel
)
{
continue
}
...
...
backend/internal/service/openai_gateway_service_test.go
View file @
52ad7c6e
...
...
@@ -3,6 +3,7 @@ package service
import
(
"bufio"
"bytes"
"context"
"errors"
"io"
"net/http"
...
...
@@ -15,6 +16,129 @@ import (
"github.com/gin-gonic/gin"
)
type
stubOpenAIAccountRepo
struct
{
AccountRepository
accounts
[]
Account
}
func
(
r
stubOpenAIAccountRepo
)
ListSchedulableByGroupIDAndPlatform
(
ctx
context
.
Context
,
groupID
int64
,
platform
string
)
([]
Account
,
error
)
{
return
append
([]
Account
(
nil
),
r
.
accounts
...
),
nil
}
func
(
r
stubOpenAIAccountRepo
)
ListSchedulableByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
Account
,
error
)
{
return
append
([]
Account
(
nil
),
r
.
accounts
...
),
nil
}
type
stubConcurrencyCache
struct
{
ConcurrencyCache
}
func
(
c
stubConcurrencyCache
)
AcquireAccountSlot
(
ctx
context
.
Context
,
accountID
int64
,
maxConcurrency
int
,
requestID
string
)
(
bool
,
error
)
{
return
true
,
nil
}
func
(
c
stubConcurrencyCache
)
ReleaseAccountSlot
(
ctx
context
.
Context
,
accountID
int64
,
requestID
string
)
error
{
return
nil
}
func
(
c
stubConcurrencyCache
)
GetAccountsLoadBatch
(
ctx
context
.
Context
,
accounts
[]
AccountWithConcurrency
)
(
map
[
int64
]
*
AccountLoadInfo
,
error
)
{
out
:=
make
(
map
[
int64
]
*
AccountLoadInfo
,
len
(
accounts
))
for
_
,
acc
:=
range
accounts
{
out
[
acc
.
ID
]
=
&
AccountLoadInfo
{
AccountID
:
acc
.
ID
,
LoadRate
:
0
}
}
return
out
,
nil
}
func
TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulable
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
resetAt
:=
now
.
Add
(
10
*
time
.
Minute
)
groupID
:=
int64
(
1
)
rateLimited
:=
Account
{
ID
:
1
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
0
,
RateLimitResetAt
:
&
resetAt
,
}
available
:=
Account
{
ID
:
2
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
1
,
}
svc
:=
&
OpenAIGatewayService
{
accountRepo
:
stubOpenAIAccountRepo
{
accounts
:
[]
Account
{
rateLimited
,
available
}},
concurrencyService
:
NewConcurrencyService
(
stubConcurrencyCache
{}),
}
selection
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
context
.
Background
(),
&
groupID
,
""
,
"gpt-5.2"
,
nil
)
if
err
!=
nil
{
t
.
Fatalf
(
"SelectAccountWithLoadAwareness error: %v"
,
err
)
}
if
selection
==
nil
||
selection
.
Account
==
nil
{
t
.
Fatalf
(
"expected selection with account"
)
}
if
selection
.
Account
.
ID
!=
available
.
ID
{
t
.
Fatalf
(
"expected account %d, got %d"
,
available
.
ID
,
selection
.
Account
.
ID
)
}
if
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
}
func
TestOpenAISelectAccountWithLoadAwareness_FiltersUnschedulableWhenNoConcurrencyService
(
t
*
testing
.
T
)
{
now
:=
time
.
Now
()
resetAt
:=
now
.
Add
(
10
*
time
.
Minute
)
groupID
:=
int64
(
1
)
rateLimited
:=
Account
{
ID
:
1
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
0
,
RateLimitResetAt
:
&
resetAt
,
}
available
:=
Account
{
ID
:
2
,
Platform
:
PlatformOpenAI
,
Type
:
AccountTypeAPIKey
,
Status
:
StatusActive
,
Schedulable
:
true
,
Concurrency
:
1
,
Priority
:
1
,
}
svc
:=
&
OpenAIGatewayService
{
accountRepo
:
stubOpenAIAccountRepo
{
accounts
:
[]
Account
{
rateLimited
,
available
}},
// concurrencyService is nil, forcing the non-load-batch selection path.
}
selection
,
err
:=
svc
.
SelectAccountWithLoadAwareness
(
context
.
Background
(),
&
groupID
,
""
,
"gpt-5.2"
,
nil
)
if
err
!=
nil
{
t
.
Fatalf
(
"SelectAccountWithLoadAwareness error: %v"
,
err
)
}
if
selection
==
nil
||
selection
.
Account
==
nil
{
t
.
Fatalf
(
"expected selection with account"
)
}
if
selection
.
Account
.
ID
!=
available
.
ID
{
t
.
Fatalf
(
"expected account %d, got %d"
,
available
.
ID
,
selection
.
Account
.
ID
)
}
if
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
}
func
TestOpenAIStreamingTimeout
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
cfg
:=
&
config
.
Config
{
...
...
frontend/src/components/admin/user/UserBalanceModal.vue
View file @
52ad7c6e
...
...
@@ -3,14 +3,17 @@
<form
v-if=
"user"
id=
"balance-form"
@
submit.prevent=
"handleBalanceSubmit"
class=
"space-y-5"
>
<div
class=
"flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700"
>
<div
class=
"flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"
><span
class=
"text-lg font-medium text-primary-700"
>
{{
user
.
email
.
charAt
(
0
).
toUpperCase
()
}}
</span></div>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
user
.
email
}}
</p><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.currentBalance
'
)
}}
: $
{{
user
.
balance
.
toFixed
(
2
)
}}
</p></div>
<div
class=
"flex-1"
><p
class=
"font-medium text-gray-900"
>
{{
user
.
email
}}
</p><p
class=
"text-sm text-gray-500"
>
{{
t
(
'
admin.users.currentBalance
'
)
}}
: $
{{
formatBalance
(
user
.
balance
)
}}
</p></div>
</div>
<div>
<label
class=
"input-label"
>
{{
operation
===
'
add
'
?
t
(
'
admin.users.depositAmount
'
)
:
t
(
'
admin.users.withdrawAmount
'
)
}}
</label>
<div
class=
"relative"
><div
class=
"absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500"
>
$
</div><input
v-model.number=
"form.amount"
type=
"number"
step=
"0.01"
min=
"0.01"
required
class=
"input pl-8"
/></div>
<div
class=
"relative flex gap-2"
>
<div
class=
"relative flex-1"
><div
class=
"absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500"
>
$
</div><input
v-model.number=
"form.amount"
type=
"number"
step=
"any"
min=
"0"
required
class=
"input pl-8"
/></div>
<button
v-if=
"operation === 'subtract'"
type=
"button"
@
click=
"fillAllBalance"
class=
"btn btn-secondary whitespace-nowrap"
>
{{
t
(
'
admin.users.withdrawAll
'
)
}}
</button>
</div>
</div>
<div><label
class=
"input-label"
>
{{
t
(
'
admin.users.notes
'
)
}}
</label><textarea
v-model=
"form.notes"
rows=
"3"
class=
"input"
></textarea></div>
<div
v-if=
"form.amount > 0"
class=
"rounded-xl border border-blue-200 bg-blue-50 p-4"
><div
class=
"flex items-center justify-between text-sm"
><span>
{{
t
(
'
admin.users.newBalance
'
)
}}
:
</span><span
class=
"font-bold"
>
$
{{
calculateNewBalance
()
.
toFixed
(
2
)
}}
</span></div></div>
<div
v-if=
"form.amount > 0"
class=
"rounded-xl border border-blue-200 bg-blue-50 p-4"
><div
class=
"flex items-center justify-between text-sm"
><span>
{{
t
(
'
admin.users.newBalance
'
)
}}
:
</span><span
class=
"font-bold"
>
$
{{
formatBalance
(
calculateNewBalance
())
}}
</span></div></div>
</form>
<template
#footer
>
<div
class=
"flex justify-end gap-3"
>
...
...
@@ -35,11 +38,30 @@ const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const a
const
submitting
=
ref
(
false
);
const
form
=
reactive
({
amount
:
0
,
notes
:
''
})
watch
(()
=>
props
.
show
,
(
v
)
=>
{
if
(
v
)
{
form
.
amount
=
0
;
form
.
notes
=
''
}
})
// 格式化余额:显示完整精度,去除尾部多余的0
const
formatBalance
=
(
value
:
number
)
=>
{
if
(
value
===
0
)
return
'
0.00
'
// 最多保留8位小数,去除尾部的0
const
formatted
=
value
.
toFixed
(
8
).
replace
(
/
\.?
0+$/
,
''
)
// 确保至少有2位小数
const
parts
=
formatted
.
split
(
'
.
'
)
if
(
parts
.
length
===
1
)
return
formatted
+
'
.00
'
if
(
parts
[
1
].
length
===
1
)
return
formatted
+
'
0
'
return
formatted
}
// 填入全部余额
const
fillAllBalance
=
()
=>
{
if
(
props
.
user
)
{
form
.
amount
=
props
.
user
.
balance
}
}
const
calculateNewBalance
=
()
=>
{
if
(
!
props
.
user
)
return
0
const
result
=
props
.
operation
===
'
add
'
?
props
.
user
.
balance
+
form
.
amount
:
props
.
user
.
balance
-
form
.
amount
// 避免浮点数精度问题导致的 -0.00 显示
return
result
===
0
||
Object
.
is
(
result
,
-
0
)
?
0
:
result
return
Math
.
abs
(
result
)
<
1
e
-
10
?
0
:
result
}
const
handleBalanceSubmit
=
async
()
=>
{
if
(
!
props
.
user
)
return
...
...
@@ -47,10 +69,8 @@ const handleBalanceSubmit = async () => {
appStore
.
showError
(
t
(
'
admin.users.amountRequired
'
))
return
}
// 使用小数点后两位精度比较,避免浮点数精度问题
const
amount
=
Math
.
round
(
form
.
amount
*
100
)
/
100
const
balance
=
Math
.
round
(
props
.
user
.
balance
*
100
)
/
100
if
(
props
.
operation
===
'
subtract
'
&&
amount
>
balance
)
{
// 退款时验证金额不超过实际余额
if
(
props
.
operation
===
'
subtract
'
&&
form
.
amount
>
props
.
user
.
balance
)
{
appStore
.
showError
(
t
(
'
admin.users.insufficientBalance
'
))
return
}
...
...
frontend/src/i18n/locales/en.ts
View file @
52ad7c6e
...
...
@@ -724,6 +724,7 @@ export default {
withdraw
:
'
Withdraw
'
,
depositAmount
:
'
Deposit Amount
'
,
withdrawAmount
:
'
Withdraw Amount
'
,
withdrawAll
:
'
All
'
,
currentBalance
:
'
Current Balance
'
,
depositNotesPlaceholder
:
'
e.g., New user registration bonus, promotional credit, compensation, etc.
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
52ad7c6e
...
...
@@ -780,6 +780,7 @@ export default {
withdraw
:
'
退款
'
,
depositAmount
:
'
充值金额
'
,
withdrawAmount
:
'
退款金额
'
,
withdrawAll
:
'
全部
'
,
depositNotesPlaceholder
:
'
例如:新用户注册奖励、活动充值、补偿充值等
'
,
withdrawNotesPlaceholder
:
'
例如:服务问题退款、错误充值退回、账户注销退款等
'
,
notesOptional
:
'
备注为可选项,有助于未来查账
'
,
...
...
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