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
ff8b1b4a
Unverified
Commit
ff8b1b4a
authored
Feb 03, 2026
by
Wesley Liddick
Committed by
GitHub
Feb 03, 2026
Browse files
Merge pull request #467 from slovx2/main
Antigravity 相关BUG修复及调度优化
parents
c0c9c984
4cce21b1
Changes
62
Expand all
Hide whitespace changes
Inline
Side-by-side
backend/internal/pkg/ctxkey/ctxkey.go
View file @
ff8b1b4a
...
@@ -14,6 +14,9 @@ const (
...
@@ -14,6 +14,9 @@ const (
// RetryCount 表示当前请求在网关层的重试次数(用于 Ops 记录与排障)。
// RetryCount 表示当前请求在网关层的重试次数(用于 Ops 记录与排障)。
RetryCount
Key
=
"ctx_retry_count"
RetryCount
Key
=
"ctx_retry_count"
// AccountSwitchCount 表示请求过程中发生的账号切换次数
AccountSwitchCount
Key
=
"ctx_account_switch_count"
// IsClaudeCodeClient 标识当前请求是否来自 Claude Code 客户端
// IsClaudeCodeClient 标识当前请求是否来自 Claude Code 客户端
IsClaudeCodeClient
Key
=
"ctx_is_claude_code_client"
IsClaudeCodeClient
Key
=
"ctx_is_claude_code_client"
// Group 认证后的分组信息,由 API Key 认证中间件设置
// Group 认证后的分组信息,由 API Key 认证中间件设置
...
...
backend/internal/repository/api_key_repo.go
View file @
ff8b1b4a
...
@@ -142,8 +142,11 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
...
@@ -142,8 +142,11 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group
.
FieldImagePrice4k
,
group
.
FieldImagePrice4k
,
group
.
FieldClaudeCodeOnly
,
group
.
FieldClaudeCodeOnly
,
group
.
FieldFallbackGroupID
,
group
.
FieldFallbackGroupID
,
group
.
FieldFallbackGroupIDOnInvalidRequest
,
group
.
FieldModelRoutingEnabled
,
group
.
FieldModelRoutingEnabled
,
group
.
FieldModelRouting
,
group
.
FieldModelRouting
,
group
.
FieldMcpXMLInject
,
group
.
FieldSupportedModelScopes
,
)
)
})
.
})
.
Only
(
ctx
)
Only
(
ctx
)
...
@@ -459,28 +462,31 @@ func groupEntityToService(g *dbent.Group) *service.Group {
...
@@ -459,28 +462,31 @@ func groupEntityToService(g *dbent.Group) *service.Group {
return
nil
return
nil
}
}
return
&
service
.
Group
{
return
&
service
.
Group
{
ID
:
g
.
ID
,
ID
:
g
.
ID
,
Name
:
g
.
Name
,
Name
:
g
.
Name
,
Description
:
derefString
(
g
.
Description
),
Description
:
derefString
(
g
.
Description
),
Platform
:
g
.
Platform
,
Platform
:
g
.
Platform
,
RateMultiplier
:
g
.
RateMultiplier
,
RateMultiplier
:
g
.
RateMultiplier
,
IsExclusive
:
g
.
IsExclusive
,
IsExclusive
:
g
.
IsExclusive
,
Status
:
g
.
Status
,
Status
:
g
.
Status
,
Hydrated
:
true
,
Hydrated
:
true
,
SubscriptionType
:
g
.
SubscriptionType
,
SubscriptionType
:
g
.
SubscriptionType
,
DailyLimitUSD
:
g
.
DailyLimitUsd
,
DailyLimitUSD
:
g
.
DailyLimitUsd
,
WeeklyLimitUSD
:
g
.
WeeklyLimitUsd
,
WeeklyLimitUSD
:
g
.
WeeklyLimitUsd
,
MonthlyLimitUSD
:
g
.
MonthlyLimitUsd
,
MonthlyLimitUSD
:
g
.
MonthlyLimitUsd
,
ImagePrice1K
:
g
.
ImagePrice1k
,
ImagePrice1K
:
g
.
ImagePrice1k
,
ImagePrice2K
:
g
.
ImagePrice2k
,
ImagePrice2K
:
g
.
ImagePrice2k
,
ImagePrice4K
:
g
.
ImagePrice4k
,
ImagePrice4K
:
g
.
ImagePrice4k
,
DefaultValidityDays
:
g
.
DefaultValidityDays
,
DefaultValidityDays
:
g
.
DefaultValidityDays
,
ClaudeCodeOnly
:
g
.
ClaudeCodeOnly
,
ClaudeCodeOnly
:
g
.
ClaudeCodeOnly
,
FallbackGroupID
:
g
.
FallbackGroupID
,
FallbackGroupID
:
g
.
FallbackGroupID
,
ModelRouting
:
g
.
ModelRouting
,
FallbackGroupIDOnInvalidRequest
:
g
.
FallbackGroupIDOnInvalidRequest
,
ModelRoutingEnabled
:
g
.
ModelRoutingEnabled
,
ModelRouting
:
g
.
ModelRouting
,
CreatedAt
:
g
.
CreatedAt
,
ModelRoutingEnabled
:
g
.
ModelRoutingEnabled
,
UpdatedAt
:
g
.
UpdatedAt
,
MCPXMLInject
:
g
.
McpXMLInject
,
SupportedModelScopes
:
g
.
SupportedModelScopes
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
}
}
}
}
...
...
backend/internal/repository/group_repo.go
View file @
ff8b1b4a
...
@@ -50,13 +50,18 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
...
@@ -50,13 +50,18 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
SetDefaultValidityDays
(
groupIn
.
DefaultValidityDays
)
.
SetDefaultValidityDays
(
groupIn
.
DefaultValidityDays
)
.
SetClaudeCodeOnly
(
groupIn
.
ClaudeCodeOnly
)
.
SetClaudeCodeOnly
(
groupIn
.
ClaudeCodeOnly
)
.
SetNillableFallbackGroupID
(
groupIn
.
FallbackGroupID
)
.
SetNillableFallbackGroupID
(
groupIn
.
FallbackGroupID
)
.
SetModelRoutingEnabled
(
groupIn
.
ModelRoutingEnabled
)
SetNillableFallbackGroupIDOnInvalidRequest
(
groupIn
.
FallbackGroupIDOnInvalidRequest
)
.
SetModelRoutingEnabled
(
groupIn
.
ModelRoutingEnabled
)
.
SetMcpXMLInject
(
groupIn
.
MCPXMLInject
)
// 设置模型路由配置
// 设置模型路由配置
if
groupIn
.
ModelRouting
!=
nil
{
if
groupIn
.
ModelRouting
!=
nil
{
builder
=
builder
.
SetModelRouting
(
groupIn
.
ModelRouting
)
builder
=
builder
.
SetModelRouting
(
groupIn
.
ModelRouting
)
}
}
// 设置支持的模型系列(始终设置,空数组表示不限制)
builder
=
builder
.
SetSupportedModelScopes
(
groupIn
.
SupportedModelScopes
)
created
,
err
:=
builder
.
Save
(
ctx
)
created
,
err
:=
builder
.
Save
(
ctx
)
if
err
==
nil
{
if
err
==
nil
{
groupIn
.
ID
=
created
.
ID
groupIn
.
ID
=
created
.
ID
...
@@ -87,7 +92,6 @@ func (r *groupRepository) GetByIDLite(ctx context.Context, id int64) (*service.G
...
@@ -87,7 +92,6 @@ func (r *groupRepository) GetByIDLite(ctx context.Context, id int64) (*service.G
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
translatePersistenceError
(
err
,
service
.
ErrGroupNotFound
,
nil
)
return
nil
,
translatePersistenceError
(
err
,
service
.
ErrGroupNotFound
,
nil
)
}
}
return
groupEntityToService
(
m
),
nil
return
groupEntityToService
(
m
),
nil
}
}
...
@@ -108,7 +112,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
...
@@ -108,7 +112,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
SetNillableImagePrice4k
(
groupIn
.
ImagePrice4K
)
.
SetNillableImagePrice4k
(
groupIn
.
ImagePrice4K
)
.
SetDefaultValidityDays
(
groupIn
.
DefaultValidityDays
)
.
SetDefaultValidityDays
(
groupIn
.
DefaultValidityDays
)
.
SetClaudeCodeOnly
(
groupIn
.
ClaudeCodeOnly
)
.
SetClaudeCodeOnly
(
groupIn
.
ClaudeCodeOnly
)
.
SetModelRoutingEnabled
(
groupIn
.
ModelRoutingEnabled
)
SetModelRoutingEnabled
(
groupIn
.
ModelRoutingEnabled
)
.
SetMcpXMLInject
(
groupIn
.
MCPXMLInject
)
// 处理 FallbackGroupID:nil 时清除,否则设置
// 处理 FallbackGroupID:nil 时清除,否则设置
if
groupIn
.
FallbackGroupID
!=
nil
{
if
groupIn
.
FallbackGroupID
!=
nil
{
...
@@ -116,6 +121,12 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
...
@@ -116,6 +121,12 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
}
else
{
}
else
{
builder
=
builder
.
ClearFallbackGroupID
()
builder
=
builder
.
ClearFallbackGroupID
()
}
}
// 处理 FallbackGroupIDOnInvalidRequest:nil 时清除,否则设置
if
groupIn
.
FallbackGroupIDOnInvalidRequest
!=
nil
{
builder
=
builder
.
SetFallbackGroupIDOnInvalidRequest
(
*
groupIn
.
FallbackGroupIDOnInvalidRequest
)
}
else
{
builder
=
builder
.
ClearFallbackGroupIDOnInvalidRequest
()
}
// 处理 ModelRouting:nil 时清除,否则设置
// 处理 ModelRouting:nil 时清除,否则设置
if
groupIn
.
ModelRouting
!=
nil
{
if
groupIn
.
ModelRouting
!=
nil
{
...
@@ -124,6 +135,9 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
...
@@ -124,6 +135,9 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
builder
=
builder
.
ClearModelRouting
()
builder
=
builder
.
ClearModelRouting
()
}
}
// 处理 SupportedModelScopes(始终设置,空数组表示不限制)
builder
=
builder
.
SetSupportedModelScopes
(
groupIn
.
SupportedModelScopes
)
updated
,
err
:=
builder
.
Save
(
ctx
)
updated
,
err
:=
builder
.
Save
(
ctx
)
if
err
!=
nil
{
if
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrGroupNotFound
,
service
.
ErrGroupExists
)
return
translatePersistenceError
(
err
,
service
.
ErrGroupNotFound
,
service
.
ErrGroupExists
)
...
...
backend/internal/repository/ops_repo_metrics.go
View file @
ff8b1b4a
...
@@ -43,6 +43,7 @@ INSERT INTO ops_system_metrics (
...
@@ -43,6 +43,7 @@ INSERT INTO ops_system_metrics (
upstream_529_count,
upstream_529_count,
token_consumed,
token_consumed,
account_switch_count,
qps,
qps,
tps,
tps,
...
@@ -81,14 +82,14 @@ INSERT INTO ops_system_metrics (
...
@@ -81,14 +82,14 @@ INSERT INTO ops_system_metrics (
$1,$2,$3,$4,
$1,$2,$3,$4,
$5,$6,$7,$8,
$5,$6,$7,$8,
$9,$10,$11,
$9,$10,$11,
$12,$13,$14,
$12,$13,$14,
$15,
$15,
$16,$17,$18,$19,$20,
$16,$17,$18,$19,$20,
$21,
$21,
$22,$23,$24,$25,$26,
$22,$23,$24,$25,$26,
$27,
$27,
$28,$29,$30,
$28,$29,$30,
$31,
$3
1
,$3
2
,
$3
2
,$3
3
,
$3
3
,$3
4
,
$3
4
,$3
5
,
$35,
$36,$37,
$36,$37,
$38,
$3
8
,$
39
$3
9
,$
40
)`
)`
_
,
err
:=
r
.
db
.
ExecContext
(
_
,
err
:=
r
.
db
.
ExecContext
(
...
@@ -109,6 +110,7 @@ INSERT INTO ops_system_metrics (
...
@@ -109,6 +110,7 @@ INSERT INTO ops_system_metrics (
input
.
Upstream529Count
,
input
.
Upstream529Count
,
input
.
TokenConsumed
,
input
.
TokenConsumed
,
input
.
AccountSwitchCount
,
opsNullFloat64
(
input
.
QPS
),
opsNullFloat64
(
input
.
QPS
),
opsNullFloat64
(
input
.
TPS
),
opsNullFloat64
(
input
.
TPS
),
...
@@ -177,7 +179,8 @@ SELECT
...
@@ -177,7 +179,8 @@ SELECT
db_conn_waiting,
db_conn_waiting,
goroutine_count,
goroutine_count,
concurrency_queue_depth
concurrency_queue_depth,
account_switch_count
FROM ops_system_metrics
FROM ops_system_metrics
WHERE window_minutes = $1
WHERE window_minutes = $1
AND platform IS NULL
AND platform IS NULL
...
@@ -199,6 +202,7 @@ LIMIT 1`
...
@@ -199,6 +202,7 @@ LIMIT 1`
var
dbWaiting
sql
.
NullInt64
var
dbWaiting
sql
.
NullInt64
var
goroutines
sql
.
NullInt64
var
goroutines
sql
.
NullInt64
var
queueDepth
sql
.
NullInt64
var
queueDepth
sql
.
NullInt64
var
accountSwitchCount
sql
.
NullInt64
if
err
:=
r
.
db
.
QueryRowContext
(
ctx
,
q
,
windowMinutes
)
.
Scan
(
if
err
:=
r
.
db
.
QueryRowContext
(
ctx
,
q
,
windowMinutes
)
.
Scan
(
&
out
.
ID
,
&
out
.
ID
,
...
@@ -217,6 +221,7 @@ LIMIT 1`
...
@@ -217,6 +221,7 @@ LIMIT 1`
&
dbWaiting
,
&
dbWaiting
,
&
goroutines
,
&
goroutines
,
&
queueDepth
,
&
queueDepth
,
&
accountSwitchCount
,
);
err
!=
nil
{
);
err
!=
nil
{
return
nil
,
err
return
nil
,
err
}
}
...
@@ -273,6 +278,10 @@ LIMIT 1`
...
@@ -273,6 +278,10 @@ LIMIT 1`
v
:=
int
(
queueDepth
.
Int64
)
v
:=
int
(
queueDepth
.
Int64
)
out
.
ConcurrencyQueueDepth
=
&
v
out
.
ConcurrencyQueueDepth
=
&
v
}
}
if
accountSwitchCount
.
Valid
{
v
:=
accountSwitchCount
.
Int64
out
.
AccountSwitchCount
=
&
v
}
return
&
out
,
nil
return
&
out
,
nil
}
}
...
...
backend/internal/repository/ops_repo_trends.go
View file @
ff8b1b4a
...
@@ -56,18 +56,44 @@ error_buckets AS (
...
@@ -56,18 +56,44 @@ error_buckets AS (
AND COALESCE(status_code, 0) >= 400
AND COALESCE(status_code, 0) >= 400
GROUP BY 1
GROUP BY 1
),
),
switch_buckets AS (
SELECT `
+
errorBucketExpr
+
` AS bucket,
COALESCE(SUM(CASE
WHEN split_part(ev->>'kind', ':', 1) IN ('failover', 'retry_exhausted_failover', 'failover_on_400') THEN 1
ELSE 0
END), 0) AS switch_count
FROM ops_error_logs
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(NULLIF(upstream_errors, 'null'::jsonb), '[]'::jsonb)
) AS ev
`
+
errorWhere
+
`
AND upstream_errors IS NOT NULL
GROUP BY 1
),
combined AS (
combined AS (
SELECT COALESCE(u.bucket, e.bucket) AS bucket,
SELECT
COALESCE(u.success_count, 0) AS success_count,
bucket,
COALESCE(e.error_count, 0) AS error_count,
SUM(success_count) AS success_count,
COALESCE(u.token_consumed, 0) AS token_consumed
SUM(error_count) AS error_count,
FROM usage_buckets u
SUM(token_consumed) AS token_consumed,
FULL OUTER JOIN error_buckets e ON u.bucket = e.bucket
SUM(switch_count) AS switch_count
FROM (
SELECT bucket, success_count, 0 AS error_count, token_consumed, 0 AS switch_count
FROM usage_buckets
UNION ALL
SELECT bucket, 0, error_count, 0, 0
FROM error_buckets
UNION ALL
SELECT bucket, 0, 0, 0, switch_count
FROM switch_buckets
) t
GROUP BY bucket
)
)
SELECT
SELECT
bucket,
bucket,
(success_count + error_count) AS request_count,
(success_count + error_count) AS request_count,
token_consumed
token_consumed,
switch_count
FROM combined
FROM combined
ORDER BY bucket ASC`
ORDER BY bucket ASC`
...
@@ -84,13 +110,18 @@ ORDER BY bucket ASC`
...
@@ -84,13 +110,18 @@ ORDER BY bucket ASC`
var
bucket
time
.
Time
var
bucket
time
.
Time
var
requests
int64
var
requests
int64
var
tokens
sql
.
NullInt64
var
tokens
sql
.
NullInt64
if
err
:=
rows
.
Scan
(
&
bucket
,
&
requests
,
&
tokens
);
err
!=
nil
{
var
switches
sql
.
NullInt64
if
err
:=
rows
.
Scan
(
&
bucket
,
&
requests
,
&
tokens
,
&
switches
);
err
!=
nil
{
return
nil
,
err
return
nil
,
err
}
}
tokenConsumed
:=
int64
(
0
)
tokenConsumed
:=
int64
(
0
)
if
tokens
.
Valid
{
if
tokens
.
Valid
{
tokenConsumed
=
tokens
.
Int64
tokenConsumed
=
tokens
.
Int64
}
}
switchCount
:=
int64
(
0
)
if
switches
.
Valid
{
switchCount
=
switches
.
Int64
}
denom
:=
float64
(
bucketSeconds
)
denom
:=
float64
(
bucketSeconds
)
if
denom
<=
0
{
if
denom
<=
0
{
...
@@ -103,6 +134,7 @@ ORDER BY bucket ASC`
...
@@ -103,6 +134,7 @@ ORDER BY bucket ASC`
BucketStart
:
bucket
.
UTC
(),
BucketStart
:
bucket
.
UTC
(),
RequestCount
:
requests
,
RequestCount
:
requests
,
TokenConsumed
:
tokenConsumed
,
TokenConsumed
:
tokenConsumed
,
SwitchCount
:
switchCount
,
QPS
:
qps
,
QPS
:
qps
,
TPS
:
tps
,
TPS
:
tps
,
})
})
...
@@ -385,6 +417,7 @@ func fillOpsThroughputBuckets(start, end time.Time, bucketSeconds int, points []
...
@@ -385,6 +417,7 @@ func fillOpsThroughputBuckets(start, end time.Time, bucketSeconds int, points []
BucketStart
:
cursor
,
BucketStart
:
cursor
,
RequestCount
:
0
,
RequestCount
:
0
,
TokenConsumed
:
0
,
TokenConsumed
:
0
,
SwitchCount
:
0
,
QPS
:
0
,
QPS
:
0
,
TPS
:
0
,
TPS
:
0
,
})
})
...
...
backend/internal/server/api_contract_test.go
View file @
ff8b1b4a
...
@@ -186,6 +186,7 @@ func TestAPIContracts(t *testing.T) {
...
@@ -186,6 +186,7 @@ func TestAPIContracts(t *testing.T) {
"image_price_4k": null,
"image_price_4k": null,
"claude_code_only": false,
"claude_code_only": false,
"fallback_group_id": null,
"fallback_group_id": null,
"fallback_group_id_on_invalid_request": null,
"created_at": "2025-01-02T03:04:05Z",
"created_at": "2025-01-02T03:04:05Z",
"updated_at": "2025-01-02T03:04:05Z"
"updated_at": "2025-01-02T03:04:05Z"
}
}
...
@@ -607,7 +608,7 @@ func newContractDeps(t *testing.T) *contractDeps {
...
@@ -607,7 +608,7 @@ func newContractDeps(t *testing.T) *contractDeps {
settingService
:=
service
.
NewSettingService
(
settingRepo
,
cfg
)
settingService
:=
service
.
NewSettingService
(
settingRepo
,
cfg
)
adminService
:=
service
.
NewAdminService
(
userRepo
,
groupRepo
,
&
accountRepo
,
proxyRepo
,
apiKeyRepo
,
redeemRepo
,
nil
,
nil
,
nil
,
nil
)
adminService
:=
service
.
NewAdminService
(
userRepo
,
groupRepo
,
&
accountRepo
,
proxyRepo
,
apiKeyRepo
,
redeemRepo
,
nil
,
nil
,
nil
,
nil
)
authHandler
:=
handler
.
NewAuthHandler
(
cfg
,
nil
,
userService
,
settingService
,
nil
,
nil
,
nil
)
authHandler
:=
handler
.
NewAuthHandler
(
cfg
,
nil
,
userService
,
settingService
,
nil
,
redeemService
,
nil
)
apiKeyHandler
:=
handler
.
NewAPIKeyHandler
(
apiKeyService
)
apiKeyHandler
:=
handler
.
NewAPIKeyHandler
(
apiKeyService
)
usageHandler
:=
handler
.
NewUsageHandler
(
usageService
,
apiKeyService
)
usageHandler
:=
handler
.
NewUsageHandler
(
usageService
,
apiKeyService
)
adminSettingHandler
:=
adminhandler
.
NewSettingHandler
(
settingService
,
nil
,
nil
,
nil
)
adminSettingHandler
:=
adminhandler
.
NewSettingHandler
(
settingService
,
nil
,
nil
,
nil
)
...
...
backend/internal/service/admin_service.go
View file @
ff8b1b4a
...
@@ -111,9 +111,14 @@ type CreateGroupInput struct {
...
@@ -111,9 +111,14 @@ type CreateGroupInput struct {
ImagePrice4K
*
float64
ImagePrice4K
*
float64
ClaudeCodeOnly
bool
// 仅允许 Claude Code 客户端
ClaudeCodeOnly
bool
// 仅允许 Claude Code 客户端
FallbackGroupID
*
int64
// 降级分组 ID
FallbackGroupID
*
int64
// 降级分组 ID
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
FallbackGroupIDOnInvalidRequest
*
int64
// 模型路由配置(仅 anthropic 平台使用)
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting
map
[
string
][]
int64
ModelRouting
map
[
string
][]
int64
ModelRoutingEnabled
bool
// 是否启用模型路由
ModelRoutingEnabled
bool
// 是否启用模型路由
MCPXMLInject
*
bool
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes
[]
string
// 从指定分组复制账号(创建分组后在同一事务内绑定)
// 从指定分组复制账号(创建分组后在同一事务内绑定)
CopyAccountsFromGroupIDs
[]
int64
CopyAccountsFromGroupIDs
[]
int64
}
}
...
@@ -135,9 +140,14 @@ type UpdateGroupInput struct {
...
@@ -135,9 +140,14 @@ type UpdateGroupInput struct {
ImagePrice4K
*
float64
ImagePrice4K
*
float64
ClaudeCodeOnly
*
bool
// 仅允许 Claude Code 客户端
ClaudeCodeOnly
*
bool
// 仅允许 Claude Code 客户端
FallbackGroupID
*
int64
// 降级分组 ID
FallbackGroupID
*
int64
// 降级分组 ID
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
FallbackGroupIDOnInvalidRequest
*
int64
// 模型路由配置(仅 anthropic 平台使用)
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting
map
[
string
][]
int64
ModelRouting
map
[
string
][]
int64
ModelRoutingEnabled
*
bool
// 是否启用模型路由
ModelRoutingEnabled
*
bool
// 是否启用模型路由
MCPXMLInject
*
bool
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes
*
[]
string
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
CopyAccountsFromGroupIDs
[]
int64
CopyAccountsFromGroupIDs
[]
int64
}
}
...
@@ -594,6 +604,22 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
...
@@ -594,6 +604,22 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
return
nil
,
err
return
nil
,
err
}
}
}
}
fallbackOnInvalidRequest
:=
input
.
FallbackGroupIDOnInvalidRequest
if
fallbackOnInvalidRequest
!=
nil
&&
*
fallbackOnInvalidRequest
<=
0
{
fallbackOnInvalidRequest
=
nil
}
// 校验无效请求兜底分组
if
fallbackOnInvalidRequest
!=
nil
{
if
err
:=
s
.
validateFallbackGroupOnInvalidRequest
(
ctx
,
0
,
platform
,
subscriptionType
,
*
fallbackOnInvalidRequest
);
err
!=
nil
{
return
nil
,
err
}
}
// MCPXMLInject:默认为 true,仅当显式传入 false 时关闭
mcpXMLInject
:=
true
if
input
.
MCPXMLInject
!=
nil
{
mcpXMLInject
=
*
input
.
MCPXMLInject
}
// 如果指定了复制账号的源分组,先获取账号 ID 列表
// 如果指定了复制账号的源分组,先获取账号 ID 列表
var
accountIDsToCopy
[]
int64
var
accountIDsToCopy
[]
int64
...
@@ -628,22 +654,25 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
...
@@ -628,22 +654,25 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
}
}
group
:=
&
Group
{
group
:=
&
Group
{
Name
:
input
.
Name
,
Name
:
input
.
Name
,
Description
:
input
.
Description
,
Description
:
input
.
Description
,
Platform
:
platform
,
Platform
:
platform
,
RateMultiplier
:
input
.
RateMultiplier
,
RateMultiplier
:
input
.
RateMultiplier
,
IsExclusive
:
input
.
IsExclusive
,
IsExclusive
:
input
.
IsExclusive
,
Status
:
StatusActive
,
Status
:
StatusActive
,
SubscriptionType
:
subscriptionType
,
SubscriptionType
:
subscriptionType
,
DailyLimitUSD
:
dailyLimit
,
DailyLimitUSD
:
dailyLimit
,
WeeklyLimitUSD
:
weeklyLimit
,
WeeklyLimitUSD
:
weeklyLimit
,
MonthlyLimitUSD
:
monthlyLimit
,
MonthlyLimitUSD
:
monthlyLimit
,
ImagePrice1K
:
imagePrice1K
,
ImagePrice1K
:
imagePrice1K
,
ImagePrice2K
:
imagePrice2K
,
ImagePrice2K
:
imagePrice2K
,
ImagePrice4K
:
imagePrice4K
,
ImagePrice4K
:
imagePrice4K
,
ClaudeCodeOnly
:
input
.
ClaudeCodeOnly
,
ClaudeCodeOnly
:
input
.
ClaudeCodeOnly
,
FallbackGroupID
:
input
.
FallbackGroupID
,
FallbackGroupID
:
input
.
FallbackGroupID
,
ModelRouting
:
input
.
ModelRouting
,
FallbackGroupIDOnInvalidRequest
:
fallbackOnInvalidRequest
,
ModelRouting
:
input
.
ModelRouting
,
MCPXMLInject
:
mcpXMLInject
,
SupportedModelScopes
:
input
.
SupportedModelScopes
,
}
}
if
err
:=
s
.
groupRepo
.
Create
(
ctx
,
group
);
err
!=
nil
{
if
err
:=
s
.
groupRepo
.
Create
(
ctx
,
group
);
err
!=
nil
{
return
nil
,
err
return
nil
,
err
...
@@ -714,6 +743,37 @@ func (s *adminServiceImpl) validateFallbackGroup(ctx context.Context, currentGro
...
@@ -714,6 +743,37 @@ func (s *adminServiceImpl) validateFallbackGroup(ctx context.Context, currentGro
}
}
}
}
// validateFallbackGroupOnInvalidRequest 校验无效请求兜底分组的有效性
// currentGroupID: 当前分组 ID(新建时为 0)
// platform/subscriptionType: 当前分组的有效平台/订阅类型
// fallbackGroupID: 兜底分组 ID
func
(
s
*
adminServiceImpl
)
validateFallbackGroupOnInvalidRequest
(
ctx
context
.
Context
,
currentGroupID
int64
,
platform
,
subscriptionType
string
,
fallbackGroupID
int64
)
error
{
if
platform
!=
PlatformAnthropic
&&
platform
!=
PlatformAntigravity
{
return
fmt
.
Errorf
(
"invalid request fallback only supported for anthropic or antigravity groups"
)
}
if
subscriptionType
==
SubscriptionTypeSubscription
{
return
fmt
.
Errorf
(
"subscription groups cannot set invalid request fallback"
)
}
if
currentGroupID
>
0
&&
currentGroupID
==
fallbackGroupID
{
return
fmt
.
Errorf
(
"cannot set self as invalid request fallback group"
)
}
fallbackGroup
,
err
:=
s
.
groupRepo
.
GetByIDLite
(
ctx
,
fallbackGroupID
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"fallback group not found: %w"
,
err
)
}
if
fallbackGroup
.
Platform
!=
PlatformAnthropic
{
return
fmt
.
Errorf
(
"fallback group must be anthropic platform"
)
}
if
fallbackGroup
.
SubscriptionType
==
SubscriptionTypeSubscription
{
return
fmt
.
Errorf
(
"fallback group cannot be subscription type"
)
}
if
fallbackGroup
.
FallbackGroupIDOnInvalidRequest
!=
nil
{
return
fmt
.
Errorf
(
"fallback group cannot have invalid request fallback configured"
)
}
return
nil
}
func
(
s
*
adminServiceImpl
)
UpdateGroup
(
ctx
context
.
Context
,
id
int64
,
input
*
UpdateGroupInput
)
(
*
Group
,
error
)
{
func
(
s
*
adminServiceImpl
)
UpdateGroup
(
ctx
context
.
Context
,
id
int64
,
input
*
UpdateGroupInput
)
(
*
Group
,
error
)
{
group
,
err
:=
s
.
groupRepo
.
GetByID
(
ctx
,
id
)
group
,
err
:=
s
.
groupRepo
.
GetByID
(
ctx
,
id
)
if
err
!=
nil
{
if
err
!=
nil
{
...
@@ -780,6 +840,20 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
...
@@ -780,6 +840,20 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
group
.
FallbackGroupID
=
nil
group
.
FallbackGroupID
=
nil
}
}
}
}
fallbackOnInvalidRequest
:=
group
.
FallbackGroupIDOnInvalidRequest
if
input
.
FallbackGroupIDOnInvalidRequest
!=
nil
{
if
*
input
.
FallbackGroupIDOnInvalidRequest
>
0
{
fallbackOnInvalidRequest
=
input
.
FallbackGroupIDOnInvalidRequest
}
else
{
fallbackOnInvalidRequest
=
nil
}
}
if
fallbackOnInvalidRequest
!=
nil
{
if
err
:=
s
.
validateFallbackGroupOnInvalidRequest
(
ctx
,
id
,
group
.
Platform
,
group
.
SubscriptionType
,
*
fallbackOnInvalidRequest
);
err
!=
nil
{
return
nil
,
err
}
}
group
.
FallbackGroupIDOnInvalidRequest
=
fallbackOnInvalidRequest
// 模型路由配置
// 模型路由配置
if
input
.
ModelRouting
!=
nil
{
if
input
.
ModelRouting
!=
nil
{
...
@@ -788,6 +862,14 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
...
@@ -788,6 +862,14 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
if
input
.
ModelRoutingEnabled
!=
nil
{
if
input
.
ModelRoutingEnabled
!=
nil
{
group
.
ModelRoutingEnabled
=
*
input
.
ModelRoutingEnabled
group
.
ModelRoutingEnabled
=
*
input
.
ModelRoutingEnabled
}
}
if
input
.
MCPXMLInject
!=
nil
{
group
.
MCPXMLInject
=
*
input
.
MCPXMLInject
}
// 支持的模型系列(仅 antigravity 平台使用)
if
input
.
SupportedModelScopes
!=
nil
{
group
.
SupportedModelScopes
=
*
input
.
SupportedModelScopes
}
if
err
:=
s
.
groupRepo
.
Update
(
ctx
,
group
);
err
!=
nil
{
if
err
:=
s
.
groupRepo
.
Update
(
ctx
,
group
);
err
!=
nil
{
return
nil
,
err
return
nil
,
err
...
...
backend/internal/service/admin_service_group_test.go
View file @
ff8b1b4a
...
@@ -394,3 +394,382 @@ func (s *groupRepoStubForFallbackCycle) BindAccountsToGroup(_ context.Context, _
...
@@ -394,3 +394,382 @@ func (s *groupRepoStubForFallbackCycle) BindAccountsToGroup(_ context.Context, _
func
(
s
*
groupRepoStubForFallbackCycle
)
GetAccountIDsByGroupIDs
(
_
context
.
Context
,
_
[]
int64
)
([]
int64
,
error
)
{
func
(
s
*
groupRepoStubForFallbackCycle
)
GetAccountIDsByGroupIDs
(
_
context
.
Context
,
_
[]
int64
)
([]
int64
,
error
)
{
panic
(
"unexpected GetAccountIDsByGroupIDs call"
)
panic
(
"unexpected GetAccountIDsByGroupIDs call"
)
}
}
type
groupRepoStubForInvalidRequestFallback
struct
{
groups
map
[
int64
]
*
Group
created
*
Group
updated
*
Group
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
Create
(
_
context
.
Context
,
g
*
Group
)
error
{
s
.
created
=
g
return
nil
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
Update
(
_
context
.
Context
,
g
*
Group
)
error
{
s
.
updated
=
g
return
nil
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
GetByID
(
ctx
context
.
Context
,
id
int64
)
(
*
Group
,
error
)
{
return
s
.
GetByIDLite
(
ctx
,
id
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
GetByIDLite
(
_
context
.
Context
,
id
int64
)
(
*
Group
,
error
)
{
if
g
,
ok
:=
s
.
groups
[
id
];
ok
{
return
g
,
nil
}
return
nil
,
ErrGroupNotFound
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
Delete
(
_
context
.
Context
,
_
int64
)
error
{
panic
(
"unexpected Delete call"
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
DeleteCascade
(
_
context
.
Context
,
_
int64
)
([]
int64
,
error
)
{
panic
(
"unexpected DeleteCascade call"
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
List
(
_
context
.
Context
,
_
pagination
.
PaginationParams
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
{
panic
(
"unexpected List call"
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
ListWithFilters
(
_
context
.
Context
,
_
pagination
.
PaginationParams
,
_
,
_
,
_
string
,
_
*
bool
)
([]
Group
,
*
pagination
.
PaginationResult
,
error
)
{
panic
(
"unexpected ListWithFilters call"
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
ListActive
(
_
context
.
Context
)
([]
Group
,
error
)
{
panic
(
"unexpected ListActive call"
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
ListActiveByPlatform
(
_
context
.
Context
,
_
string
)
([]
Group
,
error
)
{
panic
(
"unexpected ListActiveByPlatform call"
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
ExistsByName
(
_
context
.
Context
,
_
string
)
(
bool
,
error
)
{
panic
(
"unexpected ExistsByName call"
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
GetAccountCount
(
_
context
.
Context
,
_
int64
)
(
int64
,
error
)
{
panic
(
"unexpected GetAccountCount call"
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
DeleteAccountGroupsByGroupID
(
_
context
.
Context
,
_
int64
)
(
int64
,
error
)
{
panic
(
"unexpected DeleteAccountGroupsByGroupID call"
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
GetAccountIDsByGroupIDs
(
_
context
.
Context
,
_
[]
int64
)
([]
int64
,
error
)
{
panic
(
"unexpected GetAccountIDsByGroupIDs call"
)
}
func
(
s
*
groupRepoStubForInvalidRequestFallback
)
BindAccountsToGroup
(
_
context
.
Context
,
_
int64
,
_
[]
int64
)
error
{
panic
(
"unexpected BindAccountsToGroup call"
)
}
func
TestAdminService_CreateGroup_InvalidRequestFallbackRejectsUnsupportedPlatform
(
t
*
testing
.
T
)
{
fallbackID
:=
int64
(
10
)
repo
:=
&
groupRepoStubForInvalidRequestFallback
{
groups
:
map
[
int64
]
*
Group
{
fallbackID
:
{
ID
:
fallbackID
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
},
},
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
_
,
err
:=
svc
.
CreateGroup
(
context
.
Background
(),
&
CreateGroupInput
{
Name
:
"g1"
,
Platform
:
PlatformOpenAI
,
SubscriptionType
:
SubscriptionTypeStandard
,
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
})
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"invalid request fallback only supported for anthropic or antigravity groups"
)
require
.
Nil
(
t
,
repo
.
created
)
}
func
TestAdminService_CreateGroup_InvalidRequestFallbackRejectsSubscription
(
t
*
testing
.
T
)
{
fallbackID
:=
int64
(
10
)
repo
:=
&
groupRepoStubForInvalidRequestFallback
{
groups
:
map
[
int64
]
*
Group
{
fallbackID
:
{
ID
:
fallbackID
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
},
},
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
_
,
err
:=
svc
.
CreateGroup
(
context
.
Background
(),
&
CreateGroupInput
{
Name
:
"g1"
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeSubscription
,
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
})
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"subscription groups cannot set invalid request fallback"
)
require
.
Nil
(
t
,
repo
.
created
)
}
func
TestAdminService_CreateGroup_InvalidRequestFallbackRejectsFallbackGroup
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
fallback
*
Group
wantMessage
string
}{
{
name
:
"openai_target"
,
fallback
:
&
Group
{
ID
:
10
,
Platform
:
PlatformOpenAI
,
SubscriptionType
:
SubscriptionTypeStandard
},
wantMessage
:
"fallback group must be anthropic platform"
,
},
{
name
:
"antigravity_target"
,
fallback
:
&
Group
{
ID
:
10
,
Platform
:
PlatformAntigravity
,
SubscriptionType
:
SubscriptionTypeStandard
},
wantMessage
:
"fallback group must be anthropic platform"
,
},
{
name
:
"subscription_group"
,
fallback
:
&
Group
{
ID
:
10
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeSubscription
},
wantMessage
:
"fallback group cannot be subscription type"
,
},
{
name
:
"nested_fallback"
,
fallback
:
&
Group
{
ID
:
10
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
,
FallbackGroupIDOnInvalidRequest
:
func
()
*
int64
{
v
:=
int64
(
99
);
return
&
v
}(),
},
wantMessage
:
"fallback group cannot have invalid request fallback configured"
,
},
}
for
_
,
tc
:=
range
tests
{
t
.
Run
(
tc
.
name
,
func
(
t
*
testing
.
T
)
{
fallbackID
:=
tc
.
fallback
.
ID
repo
:=
&
groupRepoStubForInvalidRequestFallback
{
groups
:
map
[
int64
]
*
Group
{
fallbackID
:
tc
.
fallback
,
},
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
_
,
err
:=
svc
.
CreateGroup
(
context
.
Background
(),
&
CreateGroupInput
{
Name
:
"g1"
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
,
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
})
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
tc
.
wantMessage
)
require
.
Nil
(
t
,
repo
.
created
)
})
}
}
func
TestAdminService_CreateGroup_InvalidRequestFallbackNotFound
(
t
*
testing
.
T
)
{
fallbackID
:=
int64
(
10
)
repo
:=
&
groupRepoStubForInvalidRequestFallback
{}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
_
,
err
:=
svc
.
CreateGroup
(
context
.
Background
(),
&
CreateGroupInput
{
Name
:
"g1"
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
,
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
})
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"fallback group not found"
)
require
.
Nil
(
t
,
repo
.
created
)
}
func
TestAdminService_CreateGroup_InvalidRequestFallbackAllowsAntigravity
(
t
*
testing
.
T
)
{
fallbackID
:=
int64
(
10
)
repo
:=
&
groupRepoStubForInvalidRequestFallback
{
groups
:
map
[
int64
]
*
Group
{
fallbackID
:
{
ID
:
fallbackID
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
},
},
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
group
,
err
:=
svc
.
CreateGroup
(
context
.
Background
(),
&
CreateGroupInput
{
Name
:
"g1"
,
Platform
:
PlatformAntigravity
,
SubscriptionType
:
SubscriptionTypeStandard
,
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
group
)
require
.
NotNil
(
t
,
repo
.
created
)
require
.
Equal
(
t
,
fallbackID
,
*
repo
.
created
.
FallbackGroupIDOnInvalidRequest
)
}
func
TestAdminService_CreateGroup_InvalidRequestFallbackClearsOnZero
(
t
*
testing
.
T
)
{
zero
:=
int64
(
0
)
repo
:=
&
groupRepoStubForInvalidRequestFallback
{}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
group
,
err
:=
svc
.
CreateGroup
(
context
.
Background
(),
&
CreateGroupInput
{
Name
:
"g1"
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
,
FallbackGroupIDOnInvalidRequest
:
&
zero
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
group
)
require
.
NotNil
(
t
,
repo
.
created
)
require
.
Nil
(
t
,
repo
.
created
.
FallbackGroupIDOnInvalidRequest
)
}
func
TestAdminService_UpdateGroup_InvalidRequestFallbackPlatformMismatch
(
t
*
testing
.
T
)
{
fallbackID
:=
int64
(
10
)
existing
:=
&
Group
{
ID
:
1
,
Name
:
"g1"
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
,
Status
:
StatusActive
,
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
}
repo
:=
&
groupRepoStubForInvalidRequestFallback
{
groups
:
map
[
int64
]
*
Group
{
existing
.
ID
:
existing
,
fallbackID
:
{
ID
:
fallbackID
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
},
},
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
_
,
err
:=
svc
.
UpdateGroup
(
context
.
Background
(),
existing
.
ID
,
&
UpdateGroupInput
{
Platform
:
PlatformOpenAI
,
})
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"invalid request fallback only supported for anthropic or antigravity groups"
)
require
.
Nil
(
t
,
repo
.
updated
)
}
func
TestAdminService_UpdateGroup_InvalidRequestFallbackSubscriptionMismatch
(
t
*
testing
.
T
)
{
fallbackID
:=
int64
(
10
)
existing
:=
&
Group
{
ID
:
1
,
Name
:
"g1"
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
,
Status
:
StatusActive
,
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
}
repo
:=
&
groupRepoStubForInvalidRequestFallback
{
groups
:
map
[
int64
]
*
Group
{
existing
.
ID
:
existing
,
fallbackID
:
{
ID
:
fallbackID
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
},
},
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
_
,
err
:=
svc
.
UpdateGroup
(
context
.
Background
(),
existing
.
ID
,
&
UpdateGroupInput
{
SubscriptionType
:
SubscriptionTypeSubscription
,
})
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"subscription groups cannot set invalid request fallback"
)
require
.
Nil
(
t
,
repo
.
updated
)
}
func
TestAdminService_UpdateGroup_InvalidRequestFallbackClearsOnZero
(
t
*
testing
.
T
)
{
fallbackID
:=
int64
(
10
)
existing
:=
&
Group
{
ID
:
1
,
Name
:
"g1"
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
,
Status
:
StatusActive
,
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
}
repo
:=
&
groupRepoStubForInvalidRequestFallback
{
groups
:
map
[
int64
]
*
Group
{
existing
.
ID
:
existing
,
fallbackID
:
{
ID
:
fallbackID
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
},
},
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
clear
:=
int64
(
0
)
group
,
err
:=
svc
.
UpdateGroup
(
context
.
Background
(),
existing
.
ID
,
&
UpdateGroupInput
{
Platform
:
PlatformOpenAI
,
FallbackGroupIDOnInvalidRequest
:
&
clear
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
group
)
require
.
NotNil
(
t
,
repo
.
updated
)
require
.
Nil
(
t
,
repo
.
updated
.
FallbackGroupIDOnInvalidRequest
)
}
func
TestAdminService_UpdateGroup_InvalidRequestFallbackRejectsFallbackGroup
(
t
*
testing
.
T
)
{
fallbackID
:=
int64
(
10
)
existing
:=
&
Group
{
ID
:
1
,
Name
:
"g1"
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
,
Status
:
StatusActive
,
}
repo
:=
&
groupRepoStubForInvalidRequestFallback
{
groups
:
map
[
int64
]
*
Group
{
existing
.
ID
:
existing
,
fallbackID
:
{
ID
:
fallbackID
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeSubscription
},
},
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
_
,
err
:=
svc
.
UpdateGroup
(
context
.
Background
(),
existing
.
ID
,
&
UpdateGroupInput
{
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
})
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"fallback group cannot be subscription type"
)
require
.
Nil
(
t
,
repo
.
updated
)
}
func
TestAdminService_UpdateGroup_InvalidRequestFallbackSetSuccess
(
t
*
testing
.
T
)
{
fallbackID
:=
int64
(
10
)
existing
:=
&
Group
{
ID
:
1
,
Name
:
"g1"
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
,
Status
:
StatusActive
,
}
repo
:=
&
groupRepoStubForInvalidRequestFallback
{
groups
:
map
[
int64
]
*
Group
{
existing
.
ID
:
existing
,
fallbackID
:
{
ID
:
fallbackID
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
},
},
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
group
,
err
:=
svc
.
UpdateGroup
(
context
.
Background
(),
existing
.
ID
,
&
UpdateGroupInput
{
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
group
)
require
.
NotNil
(
t
,
repo
.
updated
)
require
.
Equal
(
t
,
fallbackID
,
*
repo
.
updated
.
FallbackGroupIDOnInvalidRequest
)
}
func
TestAdminService_UpdateGroup_InvalidRequestFallbackAllowsAntigravity
(
t
*
testing
.
T
)
{
fallbackID
:=
int64
(
10
)
existing
:=
&
Group
{
ID
:
1
,
Name
:
"g1"
,
Platform
:
PlatformAntigravity
,
SubscriptionType
:
SubscriptionTypeStandard
,
Status
:
StatusActive
,
}
repo
:=
&
groupRepoStubForInvalidRequestFallback
{
groups
:
map
[
int64
]
*
Group
{
existing
.
ID
:
existing
,
fallbackID
:
{
ID
:
fallbackID
,
Platform
:
PlatformAnthropic
,
SubscriptionType
:
SubscriptionTypeStandard
},
},
}
svc
:=
&
adminServiceImpl
{
groupRepo
:
repo
}
group
,
err
:=
svc
.
UpdateGroup
(
context
.
Background
(),
existing
.
ID
,
&
UpdateGroupInput
{
FallbackGroupIDOnInvalidRequest
:
&
fallbackID
,
})
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
group
)
require
.
NotNil
(
t
,
repo
.
updated
)
require
.
Equal
(
t
,
fallbackID
,
*
repo
.
updated
.
FallbackGroupIDOnInvalidRequest
)
}
backend/internal/service/antigravity_gateway_service.go
View file @
ff8b1b4a
This diff is collapsed.
Click to expand it.
backend/internal/service/antigravity_gateway_service_test.go
View file @
ff8b1b4a
package
service
package
service
import
(
import
(
"bytes"
"context"
"encoding/json"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/require"
)
)
...
@@ -81,3 +87,106 @@ func TestStripThinkingFromClaudeRequest_DoesNotDowngradeTools(t *testing.T) {
...
@@ -81,3 +87,106 @@ func TestStripThinkingFromClaudeRequest_DoesNotDowngradeTools(t *testing.T) {
require
.
Equal
(
t
,
"secret plan"
,
blocks
[
0
][
"text"
])
require
.
Equal
(
t
,
"secret plan"
,
blocks
[
0
][
"text"
])
require
.
Equal
(
t
,
"tool_use"
,
blocks
[
1
][
"type"
])
require
.
Equal
(
t
,
"tool_use"
,
blocks
[
1
][
"type"
])
}
}
func
TestIsPromptTooLongError
(
t
*
testing
.
T
)
{
require
.
True
(
t
,
isPromptTooLongError
([]
byte
(
`{"error":{"message":"Prompt is too long"}}`
)))
require
.
True
(
t
,
isPromptTooLongError
([]
byte
(
`{"message":"Prompt is too long"}`
)))
require
.
False
(
t
,
isPromptTooLongError
([]
byte
(
`{"error":{"message":"other"}}`
)))
}
type
httpUpstreamStub
struct
{
resp
*
http
.
Response
err
error
}
func
(
s
*
httpUpstreamStub
)
Do
(
_
*
http
.
Request
,
_
string
,
_
int64
,
_
int
)
(
*
http
.
Response
,
error
)
{
return
s
.
resp
,
s
.
err
}
func
(
s
*
httpUpstreamStub
)
DoWithTLS
(
_
*
http
.
Request
,
_
string
,
_
int64
,
_
int
,
_
bool
)
(
*
http
.
Response
,
error
)
{
return
s
.
resp
,
s
.
err
}
func
TestAntigravityGatewayService_Forward_PromptTooLong
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
writer
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
writer
)
body
,
err
:=
json
.
Marshal
(
map
[
string
]
any
{
"model"
:
"claude-opus-4-5"
,
"messages"
:
[]
map
[
string
]
any
{
{
"role"
:
"user"
,
"content"
:
"hi"
},
},
"max_tokens"
:
1
,
"stream"
:
false
,
})
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/v1/messages"
,
bytes
.
NewReader
(
body
))
c
.
Request
=
req
respBody
:=
[]
byte
(
`{"error":{"message":"Prompt is too long"}}`
)
resp
:=
&
http
.
Response
{
StatusCode
:
http
.
StatusBadRequest
,
Header
:
http
.
Header
{
"X-Request-Id"
:
[]
string
{
"req-1"
}},
Body
:
io
.
NopCloser
(
bytes
.
NewReader
(
respBody
)),
}
svc
:=
&
AntigravityGatewayService
{
tokenProvider
:
&
AntigravityTokenProvider
{},
httpUpstream
:
&
httpUpstreamStub
{
resp
:
resp
},
}
account
:=
&
Account
{
ID
:
1
,
Name
:
"acc-1"
,
Platform
:
PlatformAntigravity
,
Type
:
AccountTypeOAuth
,
Status
:
StatusActive
,
Concurrency
:
1
,
Credentials
:
map
[
string
]
any
{
"access_token"
:
"token"
,
},
}
result
,
err
:=
svc
.
Forward
(
context
.
Background
(),
c
,
account
,
body
)
require
.
Nil
(
t
,
result
)
var
promptErr
*
PromptTooLongError
require
.
ErrorAs
(
t
,
err
,
&
promptErr
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
promptErr
.
StatusCode
)
require
.
Equal
(
t
,
"req-1"
,
promptErr
.
RequestID
)
require
.
NotEmpty
(
t
,
promptErr
.
Body
)
raw
,
ok
:=
c
.
Get
(
OpsUpstreamErrorsKey
)
require
.
True
(
t
,
ok
)
events
,
ok
:=
raw
.
([]
*
OpsUpstreamErrorEvent
)
require
.
True
(
t
,
ok
)
require
.
Len
(
t
,
events
,
1
)
require
.
Equal
(
t
,
"prompt_too_long"
,
events
[
0
]
.
Kind
)
}
func
TestAntigravityMaxRetriesForModel_AfterSwitch
(
t
*
testing
.
T
)
{
t
.
Setenv
(
antigravityMaxRetriesEnv
,
"4"
)
t
.
Setenv
(
antigravityMaxRetriesAfterSwitchEnv
,
"7"
)
t
.
Setenv
(
antigravityMaxRetriesClaudeEnv
,
""
)
t
.
Setenv
(
antigravityMaxRetriesGeminiTextEnv
,
""
)
t
.
Setenv
(
antigravityMaxRetriesGeminiImageEnv
,
""
)
got
:=
antigravityMaxRetriesForModel
(
"claude-sonnet-4-5"
,
false
)
require
.
Equal
(
t
,
4
,
got
)
got
=
antigravityMaxRetriesForModel
(
"claude-sonnet-4-5"
,
true
)
require
.
Equal
(
t
,
7
,
got
)
}
func
TestAntigravityMaxRetriesForModel_AfterSwitchFallback
(
t
*
testing
.
T
)
{
t
.
Setenv
(
antigravityMaxRetriesEnv
,
"5"
)
t
.
Setenv
(
antigravityMaxRetriesAfterSwitchEnv
,
""
)
t
.
Setenv
(
antigravityMaxRetriesClaudeEnv
,
""
)
t
.
Setenv
(
antigravityMaxRetriesGeminiTextEnv
,
""
)
t
.
Setenv
(
antigravityMaxRetriesGeminiImageEnv
,
""
)
got
:=
antigravityMaxRetriesForModel
(
"gemini-2.5-flash"
,
true
)
require
.
Equal
(
t
,
5
,
got
)
}
backend/internal/service/antigravity_quota_scope.go
View file @
ff8b1b4a
package
service
package
service
import
(
import
(
"slices"
"strings"
"strings"
"time"
"time"
)
)
...
@@ -16,6 +17,21 @@ const (
...
@@ -16,6 +17,21 @@ const (
AntigravityQuotaScopeGeminiImage
AntigravityQuotaScope
=
"gemini_image"
AntigravityQuotaScopeGeminiImage
AntigravityQuotaScope
=
"gemini_image"
)
)
// IsScopeSupported 检查给定的 scope 是否在分组支持的 scope 列表中
func
IsScopeSupported
(
supportedScopes
[]
string
,
scope
AntigravityQuotaScope
)
bool
{
if
len
(
supportedScopes
)
==
0
{
// 未配置时默认全部支持
return
true
}
supported
:=
slices
.
Contains
(
supportedScopes
,
string
(
scope
))
return
supported
}
// ResolveAntigravityQuotaScope 根据模型名称解析配额域(导出版本)
func
ResolveAntigravityQuotaScope
(
requestedModel
string
)
(
AntigravityQuotaScope
,
bool
)
{
return
resolveAntigravityQuotaScope
(
requestedModel
)
}
// resolveAntigravityQuotaScope 根据模型名称解析配额域
// resolveAntigravityQuotaScope 根据模型名称解析配额域
func
resolveAntigravityQuotaScope
(
requestedModel
string
)
(
AntigravityQuotaScope
,
bool
)
{
func
resolveAntigravityQuotaScope
(
requestedModel
string
)
(
AntigravityQuotaScope
,
bool
)
{
model
:=
normalizeAntigravityModelName
(
requestedModel
)
model
:=
normalizeAntigravityModelName
(
requestedModel
)
...
...
backend/internal/service/api_key_auth_cache.go
View file @
ff8b1b4a
...
@@ -32,25 +32,30 @@ type APIKeyAuthUserSnapshot struct {
...
@@ -32,25 +32,30 @@ type APIKeyAuthUserSnapshot struct {
// APIKeyAuthGroupSnapshot 分组快照
// APIKeyAuthGroupSnapshot 分组快照
type
APIKeyAuthGroupSnapshot
struct
{
type
APIKeyAuthGroupSnapshot
struct
{
ID
int64
`json:"id"`
ID
int64
`json:"id"`
Name
string
`json:"name"`
Name
string
`json:"name"`
Platform
string
`json:"platform"`
Platform
string
`json:"platform"`
Status
string
`json:"status"`
Status
string
`json:"status"`
SubscriptionType
string
`json:"subscription_type"`
SubscriptionType
string
`json:"subscription_type"`
RateMultiplier
float64
`json:"rate_multiplier"`
RateMultiplier
float64
`json:"rate_multiplier"`
DailyLimitUSD
*
float64
`json:"daily_limit_usd,omitempty"`
DailyLimitUSD
*
float64
`json:"daily_limit_usd,omitempty"`
WeeklyLimitUSD
*
float64
`json:"weekly_limit_usd,omitempty"`
WeeklyLimitUSD
*
float64
`json:"weekly_limit_usd,omitempty"`
MonthlyLimitUSD
*
float64
`json:"monthly_limit_usd,omitempty"`
MonthlyLimitUSD
*
float64
`json:"monthly_limit_usd,omitempty"`
ImagePrice1K
*
float64
`json:"image_price_1k,omitempty"`
ImagePrice1K
*
float64
`json:"image_price_1k,omitempty"`
ImagePrice2K
*
float64
`json:"image_price_2k,omitempty"`
ImagePrice2K
*
float64
`json:"image_price_2k,omitempty"`
ImagePrice4K
*
float64
`json:"image_price_4k,omitempty"`
ImagePrice4K
*
float64
`json:"image_price_4k,omitempty"`
ClaudeCodeOnly
bool
`json:"claude_code_only"`
ClaudeCodeOnly
bool
`json:"claude_code_only"`
FallbackGroupID
*
int64
`json:"fallback_group_id,omitempty"`
FallbackGroupID
*
int64
`json:"fallback_group_id,omitempty"`
FallbackGroupIDOnInvalidRequest
*
int64
`json:"fallback_group_id_on_invalid_request,omitempty"`
// Model routing is used by gateway account selection, so it must be part of auth cache snapshot.
// Model routing is used by gateway account selection, so it must be part of auth cache snapshot.
// Only anthropic groups use these fields; others may leave them empty.
// Only anthropic groups use these fields; others may leave them empty.
ModelRouting
map
[
string
][]
int64
`json:"model_routing,omitempty"`
ModelRouting
map
[
string
][]
int64
`json:"model_routing,omitempty"`
ModelRoutingEnabled
bool
`json:"model_routing_enabled"`
ModelRoutingEnabled
bool
`json:"model_routing_enabled"`
MCPXMLInject
bool
`json:"mcp_xml_inject"`
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes
[]
string
`json:"supported_model_scopes,omitempty"`
}
}
// APIKeyAuthCacheEntry 缓存条目,支持负缓存
// APIKeyAuthCacheEntry 缓存条目,支持负缓存
...
...
backend/internal/service/api_key_auth_cache_impl.go
View file @
ff8b1b4a
...
@@ -226,22 +226,25 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
...
@@ -226,22 +226,25 @@ func (s *APIKeyService) snapshotFromAPIKey(apiKey *APIKey) *APIKeyAuthSnapshot {
}
}
if
apiKey
.
Group
!=
nil
{
if
apiKey
.
Group
!=
nil
{
snapshot
.
Group
=
&
APIKeyAuthGroupSnapshot
{
snapshot
.
Group
=
&
APIKeyAuthGroupSnapshot
{
ID
:
apiKey
.
Group
.
ID
,
ID
:
apiKey
.
Group
.
ID
,
Name
:
apiKey
.
Group
.
Name
,
Name
:
apiKey
.
Group
.
Name
,
Platform
:
apiKey
.
Group
.
Platform
,
Platform
:
apiKey
.
Group
.
Platform
,
Status
:
apiKey
.
Group
.
Status
,
Status
:
apiKey
.
Group
.
Status
,
SubscriptionType
:
apiKey
.
Group
.
SubscriptionType
,
SubscriptionType
:
apiKey
.
Group
.
SubscriptionType
,
RateMultiplier
:
apiKey
.
Group
.
RateMultiplier
,
RateMultiplier
:
apiKey
.
Group
.
RateMultiplier
,
DailyLimitUSD
:
apiKey
.
Group
.
DailyLimitUSD
,
DailyLimitUSD
:
apiKey
.
Group
.
DailyLimitUSD
,
WeeklyLimitUSD
:
apiKey
.
Group
.
WeeklyLimitUSD
,
WeeklyLimitUSD
:
apiKey
.
Group
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
apiKey
.
Group
.
MonthlyLimitUSD
,
MonthlyLimitUSD
:
apiKey
.
Group
.
MonthlyLimitUSD
,
ImagePrice1K
:
apiKey
.
Group
.
ImagePrice1K
,
ImagePrice1K
:
apiKey
.
Group
.
ImagePrice1K
,
ImagePrice2K
:
apiKey
.
Group
.
ImagePrice2K
,
ImagePrice2K
:
apiKey
.
Group
.
ImagePrice2K
,
ImagePrice4K
:
apiKey
.
Group
.
ImagePrice4K
,
ImagePrice4K
:
apiKey
.
Group
.
ImagePrice4K
,
ClaudeCodeOnly
:
apiKey
.
Group
.
ClaudeCodeOnly
,
ClaudeCodeOnly
:
apiKey
.
Group
.
ClaudeCodeOnly
,
FallbackGroupID
:
apiKey
.
Group
.
FallbackGroupID
,
FallbackGroupID
:
apiKey
.
Group
.
FallbackGroupID
,
ModelRouting
:
apiKey
.
Group
.
ModelRouting
,
FallbackGroupIDOnInvalidRequest
:
apiKey
.
Group
.
FallbackGroupIDOnInvalidRequest
,
ModelRoutingEnabled
:
apiKey
.
Group
.
ModelRoutingEnabled
,
ModelRouting
:
apiKey
.
Group
.
ModelRouting
,
ModelRoutingEnabled
:
apiKey
.
Group
.
ModelRoutingEnabled
,
MCPXMLInject
:
apiKey
.
Group
.
MCPXMLInject
,
SupportedModelScopes
:
apiKey
.
Group
.
SupportedModelScopes
,
}
}
}
}
return
snapshot
return
snapshot
...
@@ -272,23 +275,26 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
...
@@ -272,23 +275,26 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
}
}
if
snapshot
.
Group
!=
nil
{
if
snapshot
.
Group
!=
nil
{
apiKey
.
Group
=
&
Group
{
apiKey
.
Group
=
&
Group
{
ID
:
snapshot
.
Group
.
ID
,
ID
:
snapshot
.
Group
.
ID
,
Name
:
snapshot
.
Group
.
Name
,
Name
:
snapshot
.
Group
.
Name
,
Platform
:
snapshot
.
Group
.
Platform
,
Platform
:
snapshot
.
Group
.
Platform
,
Status
:
snapshot
.
Group
.
Status
,
Status
:
snapshot
.
Group
.
Status
,
Hydrated
:
true
,
Hydrated
:
true
,
SubscriptionType
:
snapshot
.
Group
.
SubscriptionType
,
SubscriptionType
:
snapshot
.
Group
.
SubscriptionType
,
RateMultiplier
:
snapshot
.
Group
.
RateMultiplier
,
RateMultiplier
:
snapshot
.
Group
.
RateMultiplier
,
DailyLimitUSD
:
snapshot
.
Group
.
DailyLimitUSD
,
DailyLimitUSD
:
snapshot
.
Group
.
DailyLimitUSD
,
WeeklyLimitUSD
:
snapshot
.
Group
.
WeeklyLimitUSD
,
WeeklyLimitUSD
:
snapshot
.
Group
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
snapshot
.
Group
.
MonthlyLimitUSD
,
MonthlyLimitUSD
:
snapshot
.
Group
.
MonthlyLimitUSD
,
ImagePrice1K
:
snapshot
.
Group
.
ImagePrice1K
,
ImagePrice1K
:
snapshot
.
Group
.
ImagePrice1K
,
ImagePrice2K
:
snapshot
.
Group
.
ImagePrice2K
,
ImagePrice2K
:
snapshot
.
Group
.
ImagePrice2K
,
ImagePrice4K
:
snapshot
.
Group
.
ImagePrice4K
,
ImagePrice4K
:
snapshot
.
Group
.
ImagePrice4K
,
ClaudeCodeOnly
:
snapshot
.
Group
.
ClaudeCodeOnly
,
ClaudeCodeOnly
:
snapshot
.
Group
.
ClaudeCodeOnly
,
FallbackGroupID
:
snapshot
.
Group
.
FallbackGroupID
,
FallbackGroupID
:
snapshot
.
Group
.
FallbackGroupID
,
ModelRouting
:
snapshot
.
Group
.
ModelRouting
,
FallbackGroupIDOnInvalidRequest
:
snapshot
.
Group
.
FallbackGroupIDOnInvalidRequest
,
ModelRoutingEnabled
:
snapshot
.
Group
.
ModelRoutingEnabled
,
ModelRouting
:
snapshot
.
Group
.
ModelRouting
,
ModelRoutingEnabled
:
snapshot
.
Group
.
ModelRoutingEnabled
,
MCPXMLInject
:
snapshot
.
Group
.
MCPXMLInject
,
SupportedModelScopes
:
snapshot
.
Group
.
SupportedModelScopes
,
}
}
}
}
return
apiKey
return
apiKey
...
...
backend/internal/service/auth_service.go
View file @
ff8b1b4a
...
@@ -185,7 +185,6 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
...
@@ -185,7 +185,6 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
log
.
Printf
(
"[Auth] Failed to mark invitation code as used for user %d: %v"
,
user
.
ID
,
err
)
log
.
Printf
(
"[Auth] Failed to mark invitation code as used for user %d: %v"
,
user
.
ID
,
err
)
}
}
}
}
// 应用优惠码(如果提供且功能已启用)
// 应用优惠码(如果提供且功能已启用)
if
promoCode
!=
""
&&
s
.
promoService
!=
nil
&&
s
.
settingService
!=
nil
&&
s
.
settingService
.
IsPromoCodeEnabled
(
ctx
)
{
if
promoCode
!=
""
&&
s
.
promoService
!=
nil
&&
s
.
settingService
!=
nil
&&
s
.
settingService
.
IsPromoCodeEnabled
(
ctx
)
{
if
err
:=
s
.
promoService
.
ApplyPromoCode
(
ctx
,
user
.
ID
,
promoCode
);
err
!=
nil
{
if
err
:=
s
.
promoService
.
ApplyPromoCode
(
ctx
,
user
.
ID
,
promoCode
);
err
!=
nil
{
...
...
backend/internal/service/domain_constants.go
View file @
ff8b1b4a
...
@@ -31,6 +31,7 @@ const (
...
@@ -31,6 +31,7 @@ const (
AccountTypeOAuth
=
domain
.
AccountTypeOAuth
// OAuth类型账号(full scope: profile + inference)
AccountTypeOAuth
=
domain
.
AccountTypeOAuth
// OAuth类型账号(full scope: profile + inference)
AccountTypeSetupToken
=
domain
.
AccountTypeSetupToken
// Setup Token类型账号(inference only scope)
AccountTypeSetupToken
=
domain
.
AccountTypeSetupToken
// Setup Token类型账号(inference only scope)
AccountTypeAPIKey
=
domain
.
AccountTypeAPIKey
// API Key类型账号
AccountTypeAPIKey
=
domain
.
AccountTypeAPIKey
// API Key类型账号
AccountTypeUpstream
=
domain
.
AccountTypeUpstream
// 上游透传类型账号(通过 Base URL + API Key 连接上游)
)
)
// Redeem type constants
// Redeem type constants
...
...
backend/internal/service/gateway_service.go
View file @
ff8b1b4a
...
@@ -70,6 +70,15 @@ func shortSessionHash(sessionHash string) string {
...
@@ -70,6 +70,15 @@ func shortSessionHash(sessionHash string) string {
return
sessionHash
[
:
8
]
return
sessionHash
[
:
8
]
}
}
func
normalizeClaudeModelForAnthropic
(
requestedModel
string
)
string
{
for
_
,
prefix
:=
range
anthropicPrefixMappings
{
if
strings
.
HasPrefix
(
requestedModel
,
prefix
)
{
return
prefix
}
}
return
requestedModel
}
func
redactAuthHeaderValue
(
v
string
)
string
{
func
redactAuthHeaderValue
(
v
string
)
string
{
v
=
strings
.
TrimSpace
(
v
)
v
=
strings
.
TrimSpace
(
v
)
if
v
==
""
{
if
v
==
""
{
...
@@ -252,11 +261,20 @@ var (
...
@@ -252,11 +261,20 @@ var (
"You are a file search specialist for Claude Code"
,
// Explore Agent 版
"You are a file search specialist for Claude Code"
,
// Explore Agent 版
"You are a helpful AI assistant tasked with summarizing conversations"
,
// Compact 版
"You are a helpful AI assistant tasked with summarizing conversations"
,
// Compact 版
}
}
anthropicPrefixMappings
=
[]
string
{
"claude-opus-4-5"
,
"claude-haiku-4-5"
,
"claude-sonnet-4-5"
,
}
)
)
// ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
// ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
var
ErrClaudeCodeOnly
=
errors
.
New
(
"this group only allows Claude Code clients"
)
var
ErrClaudeCodeOnly
=
errors
.
New
(
"this group only allows Claude Code clients"
)
// ErrModelScopeNotSupported 表示请求的模型系列不在分组支持的范围内
var
ErrModelScopeNotSupported
=
errors
.
New
(
"model scope not supported by this group"
)
// allowedHeaders 白名单headers(参考CRS项目)
// allowedHeaders 白名单headers(参考CRS项目)
var
allowedHeaders
=
map
[
string
]
bool
{
var
allowedHeaders
=
map
[
string
]
bool
{
"accept"
:
true
,
"accept"
:
true
,
...
@@ -1135,6 +1153,13 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
...
@@ -1135,6 +1153,13 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
log
.
Printf
(
"[ModelRoutingDebug] load-aware enabled: group_id=%v model=%s session=%s platform=%s"
,
derefGroupID
(
groupID
),
requestedModel
,
shortSessionHash
(
sessionHash
),
platform
)
log
.
Printf
(
"[ModelRoutingDebug] load-aware enabled: group_id=%v model=%s session=%s platform=%s"
,
derefGroupID
(
groupID
),
requestedModel
,
shortSessionHash
(
sessionHash
),
platform
)
}
}
// Antigravity 模型系列检查(在账号选择前检查,确保所有代码路径都经过此检查)
if
platform
==
PlatformAntigravity
&&
groupID
!=
nil
&&
requestedModel
!=
""
{
if
err
:=
s
.
checkAntigravityModelScope
(
ctx
,
*
groupID
,
requestedModel
);
err
!=
nil
{
return
nil
,
err
}
}
accounts
,
useMixed
,
err
:=
s
.
listSchedulableAccounts
(
ctx
,
groupID
,
platform
,
hasForcePlatform
)
accounts
,
useMixed
,
err
:=
s
.
listSchedulableAccounts
(
ctx
,
groupID
,
platform
,
hasForcePlatform
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
err
return
nil
,
err
...
@@ -1632,6 +1657,10 @@ func (s *GatewayService) resolveGroupByID(ctx context.Context, groupID int64) (*
...
@@ -1632,6 +1657,10 @@ func (s *GatewayService) resolveGroupByID(ctx context.Context, groupID int64) (*
return
group
,
nil
return
group
,
nil
}
}
func
(
s
*
GatewayService
)
ResolveGroupByID
(
ctx
context
.
Context
,
groupID
int64
)
(
*
Group
,
error
)
{
return
s
.
resolveGroupByID
(
ctx
,
groupID
)
}
func
(
s
*
GatewayService
)
routingAccountIDsForRequest
(
ctx
context
.
Context
,
groupID
*
int64
,
requestedModel
string
,
platform
string
)
[]
int64
{
func
(
s
*
GatewayService
)
routingAccountIDsForRequest
(
ctx
context
.
Context
,
groupID
*
int64
,
requestedModel
string
,
platform
string
)
[]
int64
{
if
groupID
==
nil
||
requestedModel
==
""
||
platform
!=
PlatformAnthropic
{
if
groupID
==
nil
||
requestedModel
==
""
||
platform
!=
PlatformAnthropic
{
return
nil
return
nil
...
@@ -1697,7 +1726,7 @@ func (s *GatewayService) checkClaudeCodeRestriction(ctx context.Context, groupID
...
@@ -1697,7 +1726,7 @@ func (s *GatewayService) checkClaudeCodeRestriction(ctx context.Context, groupID
}
}
// 强制平台模式不检查 Claude Code 限制
// 强制平台模式不检查 Claude Code 限制
if
_
,
hasForcePlatform
:=
ctx
.
Value
(
ctxkey
.
ForcePlatform
)
.
(
string
);
hasForcePlatform
{
if
forcePlatform
,
hasForcePlatform
:=
ctx
.
Value
(
ctxkey
.
ForcePlatform
)
.
(
string
);
hasForcePlatform
&&
forcePlatform
!=
""
{
return
nil
,
groupID
,
nil
return
nil
,
groupID
,
nil
}
}
...
@@ -2026,6 +2055,13 @@ func shuffleWithinPriority(accounts []*Account) {
...
@@ -2026,6 +2055,13 @@ func shuffleWithinPriority(accounts []*Account) {
// selectAccountForModelWithPlatform 选择单平台账户(完全隔离)
// selectAccountForModelWithPlatform 选择单平台账户(完全隔离)
func
(
s
*
GatewayService
)
selectAccountForModelWithPlatform
(
ctx
context
.
Context
,
groupID
*
int64
,
sessionHash
string
,
requestedModel
string
,
excludedIDs
map
[
int64
]
struct
{},
platform
string
)
(
*
Account
,
error
)
{
func
(
s
*
GatewayService
)
selectAccountForModelWithPlatform
(
ctx
context
.
Context
,
groupID
*
int64
,
sessionHash
string
,
requestedModel
string
,
excludedIDs
map
[
int64
]
struct
{},
platform
string
)
(
*
Account
,
error
)
{
// 对 Antigravity 平台,检查请求的模型系列是否在分组支持范围内
if
platform
==
PlatformAntigravity
&&
groupID
!=
nil
&&
requestedModel
!=
""
{
if
err
:=
s
.
checkAntigravityModelScope
(
ctx
,
*
groupID
,
requestedModel
);
err
!=
nil
{
return
nil
,
err
}
}
preferOAuth
:=
platform
==
PlatformGemini
preferOAuth
:=
platform
==
PlatformGemini
routingAccountIDs
:=
s
.
routingAccountIDsForRequest
(
ctx
,
groupID
,
requestedModel
,
platform
)
routingAccountIDs
:=
s
.
routingAccountIDsForRequest
(
ctx
,
groupID
,
requestedModel
,
platform
)
...
@@ -2461,6 +2497,9 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
...
@@ -2461,6 +2497,9 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
// Antigravity 平台使用专门的模型支持检查
// Antigravity 平台使用专门的模型支持检查
return
IsAntigravityModelSupported
(
requestedModel
)
return
IsAntigravityModelSupported
(
requestedModel
)
}
}
if
account
.
Platform
==
PlatformAnthropic
{
requestedModel
=
normalizeClaudeModelForAnthropic
(
requestedModel
)
}
// Gemini API Key 账户直接透传,由上游判断模型是否支持
// Gemini API Key 账户直接透传,由上游判断模型是否支持
if
account
.
Platform
==
PlatformGemini
&&
account
.
Type
==
AccountTypeAPIKey
{
if
account
.
Platform
==
PlatformGemini
&&
account
.
Type
==
AccountTypeAPIKey
{
return
true
return
true
...
@@ -2910,16 +2949,28 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
...
@@ -2910,16 +2949,28 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 强制执行 cache_control 块数量限制(最多 4 个)
// 强制执行 cache_control 块数量限制(最多 4 个)
body
=
enforceCacheControlLimit
(
body
)
body
=
enforceCacheControlLimit
(
body
)
// 应用模型映射(仅对apikey类型账号)
// 应用模型映射(APIKey 明确映射优先,其次使用 Anthropic 前缀映射)
mappedModel
:=
reqModel
mappingSource
:=
""
if
account
.
Type
==
AccountTypeAPIKey
{
if
account
.
Type
==
AccountTypeAPIKey
{
mappedModel
:
=
account
.
GetMappedModel
(
reqModel
)
mappedModel
=
account
.
GetMappedModel
(
reqModel
)
if
mappedModel
!=
reqModel
{
if
mappedModel
!=
reqModel
{
// 替换请求体中的模型名
mappingSource
=
"account"
body
=
s
.
replaceModelInBody
(
body
,
mappedModel
)
reqModel
=
mappedModel
log
.
Printf
(
"Model mapping applied: %s -> %s (account: %s)"
,
originalModel
,
mappedModel
,
account
.
Name
)
}
}
}
}
if
mappingSource
==
""
&&
account
.
Platform
==
PlatformAnthropic
{
normalized
:=
normalizeClaudeModelForAnthropic
(
reqModel
)
if
normalized
!=
reqModel
{
mappedModel
=
normalized
mappingSource
=
"prefix"
}
}
if
mappedModel
!=
reqModel
{
// 替换请求体中的模型名
body
=
s
.
replaceModelInBody
(
body
,
mappedModel
)
reqModel
=
mappedModel
log
.
Printf
(
"Model mapping applied: %s -> %s (account: %s, source=%s)"
,
originalModel
,
mappedModel
,
account
.
Name
,
mappingSource
)
}
// 获取凭证
// 获取凭证
token
,
tokenType
,
err
:=
s
.
GetAccessToken
(
ctx
,
account
)
token
,
tokenType
,
err
:=
s
.
GetAccessToken
(
ctx
,
account
)
...
@@ -4842,16 +4893,28 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
...
@@ -4842,16 +4893,28 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
return
nil
return
nil
}
}
// 应用模型映射(仅对 apikey 类型账号)
// 应用模型映射(APIKey 明确映射优先,其次使用 Anthropic 前缀映射)
if
account
.
Type
==
AccountTypeAPIKey
{
if
reqModel
!=
""
{
if
reqModel
!=
""
{
mappedModel
:=
reqModel
mappedModel
:=
account
.
GetMappedModel
(
reqModel
)
mappingSource
:=
""
if
account
.
Type
==
AccountTypeAPIKey
{
mappedModel
=
account
.
GetMappedModel
(
reqModel
)
if
mappedModel
!=
reqModel
{
if
mappedModel
!=
reqModel
{
body
=
s
.
replaceModelInBody
(
body
,
mappedModel
)
mappingSource
=
"account"
reqModel
=
mappedModel
}
log
.
Printf
(
"CountTokens model mapping applied: %s -> %s (account: %s)"
,
parsed
.
Model
,
mappedModel
,
account
.
Name
)
}
if
mappingSource
==
""
&&
account
.
Platform
==
PlatformAnthropic
{
normalized
:=
normalizeClaudeModelForAnthropic
(
reqModel
)
if
normalized
!=
reqModel
{
mappedModel
=
normalized
mappingSource
=
"prefix"
}
}
}
}
if
mappedModel
!=
reqModel
{
body
=
s
.
replaceModelInBody
(
body
,
mappedModel
)
reqModel
=
mappedModel
log
.
Printf
(
"CountTokens model mapping applied: %s -> %s (account: %s, source=%s)"
,
parsed
.
Model
,
mappedModel
,
account
.
Name
,
mappingSource
)
}
}
}
// 获取凭证
// 获取凭证
...
@@ -5103,6 +5166,27 @@ func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
...
@@ -5103,6 +5166,27 @@ func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
return
normalized
,
nil
return
normalized
,
nil
}
}
// checkAntigravityModelScope 检查 Antigravity 平台的模型系列是否在分组支持范围内
func
(
s
*
GatewayService
)
checkAntigravityModelScope
(
ctx
context
.
Context
,
groupID
int64
,
requestedModel
string
)
error
{
scope
,
ok
:=
ResolveAntigravityQuotaScope
(
requestedModel
)
if
!
ok
{
return
nil
// 无法解析 scope,跳过检查
}
group
,
err
:=
s
.
resolveGroupByID
(
ctx
,
groupID
)
if
err
!=
nil
{
return
nil
// 查询失败时放行
}
if
group
==
nil
{
return
nil
// 分组不存在时放行
}
if
!
IsScopeSupported
(
group
.
SupportedModelScopes
,
scope
)
{
return
ErrModelScopeNotSupported
}
return
nil
}
// GetAvailableModels returns the list of models available for a group
// GetAvailableModels returns the list of models available for a group
// It aggregates model_mapping keys from all schedulable accounts in the group
// It aggregates model_mapping keys from all schedulable accounts in the group
func
(
s
*
GatewayService
)
GetAvailableModels
(
ctx
context
.
Context
,
groupID
*
int64
,
platform
string
)
[]
string
{
func
(
s
*
GatewayService
)
GetAvailableModels
(
ctx
context
.
Context
,
groupID
*
int64
,
platform
string
)
[]
string
{
...
...
backend/internal/service/gemini_messages_compat_service.go
View file @
ff8b1b4a
...
@@ -977,6 +977,11 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
...
@@ -977,6 +977,11 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
return
nil
,
s
.
writeGoogleError
(
c
,
http
.
StatusBadRequest
,
"Request body is empty"
)
return
nil
,
s
.
writeGoogleError
(
c
,
http
.
StatusBadRequest
,
"Request body is empty"
)
}
}
// 过滤掉 parts 为空的消息(Gemini API 不接受空 parts)
if
filteredBody
,
err
:=
filterEmptyPartsFromGeminiRequest
(
body
);
err
==
nil
{
body
=
filteredBody
}
switch
action
{
switch
action
{
case
"generateContent"
,
"streamGenerateContent"
,
"countTokens"
:
case
"generateContent"
,
"streamGenerateContent"
,
"countTokens"
:
// ok
// ok
...
...
backend/internal/service/group.go
View file @
ff8b1b4a
...
@@ -29,6 +29,8 @@ type Group struct {
...
@@ -29,6 +29,8 @@ type Group struct {
// Claude Code 客户端限制
// Claude Code 客户端限制
ClaudeCodeOnly
bool
ClaudeCodeOnly
bool
FallbackGroupID
*
int64
FallbackGroupID
*
int64
// 无效请求兜底分组(仅 anthropic 平台使用)
FallbackGroupIDOnInvalidRequest
*
int64
// 模型路由配置
// 模型路由配置
// key: 模型匹配模式(支持 * 通配符,如 "claude-opus-*")
// key: 模型匹配模式(支持 * 通配符,如 "claude-opus-*")
...
@@ -36,6 +38,13 @@ type Group struct {
...
@@ -36,6 +38,13 @@ type Group struct {
ModelRouting
map
[
string
][]
int64
ModelRouting
map
[
string
][]
int64
ModelRoutingEnabled
bool
ModelRoutingEnabled
bool
// MCP XML 协议注入开关(仅 antigravity 平台使用)
MCPXMLInject
bool
// 支持的模型系列(仅 antigravity 平台使用)
// 可选值: claude, gemini_text, gemini_image
SupportedModelScopes
[]
string
CreatedAt
time
.
Time
CreatedAt
time
.
Time
UpdatedAt
time
.
Time
UpdatedAt
time
.
Time
...
...
backend/internal/service/ops_metrics_collector.go
View file @
ff8b1b4a
...
@@ -285,6 +285,11 @@ func (c *OpsMetricsCollector) collectAndPersist(ctx context.Context) error {
...
@@ -285,6 +285,11 @@ func (c *OpsMetricsCollector) collectAndPersist(ctx context.Context) error {
return
fmt
.
Errorf
(
"query error counts: %w"
,
err
)
return
fmt
.
Errorf
(
"query error counts: %w"
,
err
)
}
}
accountSwitchCount
,
err
:=
c
.
queryAccountSwitchCount
(
ctx
,
windowStart
,
windowEnd
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"query account switch counts: %w"
,
err
)
}
windowSeconds
:=
windowEnd
.
Sub
(
windowStart
)
.
Seconds
()
windowSeconds
:=
windowEnd
.
Sub
(
windowStart
)
.
Seconds
()
if
windowSeconds
<=
0
{
if
windowSeconds
<=
0
{
windowSeconds
=
60
windowSeconds
=
60
...
@@ -309,9 +314,10 @@ func (c *OpsMetricsCollector) collectAndPersist(ctx context.Context) error {
...
@@ -309,9 +314,10 @@ func (c *OpsMetricsCollector) collectAndPersist(ctx context.Context) error {
Upstream429Count
:
upstream429
,
Upstream429Count
:
upstream429
,
Upstream529Count
:
upstream529
,
Upstream529Count
:
upstream529
,
TokenConsumed
:
tokenConsumed
,
TokenConsumed
:
tokenConsumed
,
QPS
:
float64Ptr
(
roundTo1DP
(
qps
)),
AccountSwitchCount
:
accountSwitchCount
,
TPS
:
float64Ptr
(
roundTo1DP
(
tps
)),
QPS
:
float64Ptr
(
roundTo1DP
(
qps
)),
TPS
:
float64Ptr
(
roundTo1DP
(
tps
)),
DurationP50Ms
:
duration
.
p50
,
DurationP50Ms
:
duration
.
p50
,
DurationP90Ms
:
duration
.
p90
,
DurationP90Ms
:
duration
.
p90
,
...
@@ -551,6 +557,27 @@ WHERE created_at >= $1 AND created_at < $2`
...
@@ -551,6 +557,27 @@ WHERE created_at >= $1 AND created_at < $2`
return
errorTotal
,
businessLimited
,
errorSLA
,
upstreamExcl429529
,
upstream429
,
upstream529
,
nil
return
errorTotal
,
businessLimited
,
errorSLA
,
upstreamExcl429529
,
upstream429
,
upstream529
,
nil
}
}
func
(
c
*
OpsMetricsCollector
)
queryAccountSwitchCount
(
ctx
context
.
Context
,
start
,
end
time
.
Time
)
(
int64
,
error
)
{
q
:=
`
SELECT
COALESCE(SUM(CASE
WHEN split_part(ev->>'kind', ':', 1) IN ('failover', 'retry_exhausted_failover', 'failover_on_400') THEN 1
ELSE 0
END), 0) AS switch_count
FROM ops_error_logs o
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(NULLIF(o.upstream_errors, 'null'::jsonb), '[]'::jsonb)
) AS ev
WHERE o.created_at >= $1 AND o.created_at < $2
AND o.is_count_tokens = FALSE`
var
count
int64
if
err
:=
c
.
db
.
QueryRowContext
(
ctx
,
q
,
start
,
end
)
.
Scan
(
&
count
);
err
!=
nil
{
return
0
,
err
}
return
count
,
nil
}
type
opsCollectedSystemStats
struct
{
type
opsCollectedSystemStats
struct
{
cpuUsagePercent
*
float64
cpuUsagePercent
*
float64
memoryUsedMB
*
int64
memoryUsedMB
*
int64
...
...
backend/internal/service/ops_port.go
View file @
ff8b1b4a
...
@@ -161,7 +161,8 @@ type OpsInsertSystemMetricsInput struct {
...
@@ -161,7 +161,8 @@ type OpsInsertSystemMetricsInput struct {
Upstream429Count
int64
Upstream429Count
int64
Upstream529Count
int64
Upstream529Count
int64
TokenConsumed
int64
TokenConsumed
int64
AccountSwitchCount
int64
QPS
*
float64
QPS
*
float64
TPS
*
float64
TPS
*
float64
...
@@ -223,8 +224,9 @@ type OpsSystemMetricsSnapshot struct {
...
@@ -223,8 +224,9 @@ type OpsSystemMetricsSnapshot struct {
DBConnIdle
*
int
`json:"db_conn_idle"`
DBConnIdle
*
int
`json:"db_conn_idle"`
DBConnWaiting
*
int
`json:"db_conn_waiting"`
DBConnWaiting
*
int
`json:"db_conn_waiting"`
GoroutineCount
*
int
`json:"goroutine_count"`
GoroutineCount
*
int
`json:"goroutine_count"`
ConcurrencyQueueDepth
*
int
`json:"concurrency_queue_depth"`
ConcurrencyQueueDepth
*
int
`json:"concurrency_queue_depth"`
AccountSwitchCount
*
int64
`json:"account_switch_count"`
}
}
type
OpsUpsertJobHeartbeatInput
struct
{
type
OpsUpsertJobHeartbeatInput
struct
{
...
...
Prev
1
2
3
4
Next
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