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
bb664d9b
Commit
bb664d9b
authored
Feb 28, 2026
by
yangjianbo
Browse files
feat(sync): full code sync from release
parent
bfc7b339
Changes
245
Show whitespace changes
Inline
Side-by-side
Too many changes to show.
To preserve performance only
245 of 245+
files are displayed.
Plain diff
Email patch
backend/internal/handler/admin/openai_oauth_handler.go
View file @
bb664d9b
...
...
@@ -5,6 +5,7 @@ import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
...
...
@@ -47,7 +48,12 @@ func (h *OpenAIOAuthHandler) GenerateAuthURL(c *gin.Context) {
req
=
OpenAIGenerateAuthURLRequest
{}
}
result
,
err
:=
h
.
openaiOAuthService
.
GenerateAuthURL
(
c
.
Request
.
Context
(),
req
.
ProxyID
,
req
.
RedirectURI
)
result
,
err
:=
h
.
openaiOAuthService
.
GenerateAuthURL
(
c
.
Request
.
Context
(),
req
.
ProxyID
,
req
.
RedirectURI
,
oauthPlatformFromPath
(
c
),
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
...
...
@@ -123,7 +129,14 @@ func (h *OpenAIOAuthHandler) RefreshToken(c *gin.Context) {
}
}
tokenInfo
,
err
:=
h
.
openaiOAuthService
.
RefreshTokenWithClientID
(
c
.
Request
.
Context
(),
refreshToken
,
proxyURL
,
strings
.
TrimSpace
(
req
.
ClientID
))
// 未指定 client_id 时,根据请求路径平台自动设置默认值,避免 repository 层盲猜
clientID
:=
strings
.
TrimSpace
(
req
.
ClientID
)
if
clientID
==
""
{
platform
:=
oauthPlatformFromPath
(
c
)
clientID
,
_
=
openai
.
OAuthClientConfigByPlatform
(
platform
)
}
tokenInfo
,
err
:=
h
.
openaiOAuthService
.
RefreshTokenWithClientID
(
c
.
Request
.
Context
(),
refreshToken
,
proxyURL
,
clientID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
...
...
backend/internal/handler/admin/ops_ws_handler.go
View file @
bb664d9b
...
...
@@ -62,7 +62,8 @@ const (
)
var
wsConnCount
atomic
.
Int32
var
wsConnCountByIP
sync
.
Map
// map[string]*atomic.Int32
var
wsConnCountByIPMu
sync
.
Mutex
var
wsConnCountByIP
=
make
(
map
[
string
]
int32
)
const
qpsWSIdleStopDelay
=
30
*
time
.
Second
...
...
@@ -389,42 +390,31 @@ func tryAcquireOpsWSIPSlot(clientIP string, limit int32) bool {
if
strings
.
TrimSpace
(
clientIP
)
==
""
||
limit
<=
0
{
return
true
}
v
,
_
:=
wsConnCountByIP
.
LoadOrStore
(
clientIP
,
&
atomic
.
Int32
{})
counter
,
ok
:=
v
.
(
*
atomic
.
Int32
)
if
!
ok
{
return
false
}
for
{
current
:=
counter
.
Load
()
wsConnCountByIPMu
.
Lock
()
defer
wsConnCountByIPMu
.
Unlock
()
current
:=
wsConnCountByIP
[
clientIP
]
if
current
>=
limit
{
return
false
}
if
counter
.
CompareAndSwap
(
current
,
current
+
1
)
{
wsConnCountByIP
[
clientIP
]
=
current
+
1
return
true
}
}
}
func
releaseOpsWSIPSlot
(
clientIP
string
)
{
if
strings
.
TrimSpace
(
clientIP
)
==
""
{
return
}
v
,
ok
:=
wsConnCountByIP
.
Load
(
clientIP
)
wsConnCountByIPMu
.
Lock
()
defer
wsConnCountByIPMu
.
Unlock
()
current
,
ok
:=
wsConnCountByIP
[
clientIP
]
if
!
ok
{
return
}
counter
,
ok
:=
v
.
(
*
atomic
.
Int32
)
if
!
ok
{
if
current
<=
1
{
delete
(
wsConnCountByIP
,
clientIP
)
return
}
next
:=
counter
.
Add
(
-
1
)
if
next
<=
0
{
// Best-effort cleanup; safe even if a new slot was acquired concurrently.
wsConnCountByIP
.
Delete
(
clientIP
)
}
wsConnCountByIP
[
clientIP
]
=
current
-
1
}
func
handleQPSWebSocket
(
parentCtx
context
.
Context
,
conn
*
websocket
.
Conn
)
{
...
...
backend/internal/handler/admin/setting_handler.go
View file @
bb664d9b
package
admin
import
(
"fmt"
"log"
"strings"
"time"
...
...
@@ -20,15 +21,17 @@ type SettingHandler struct {
emailService
*
service
.
EmailService
turnstileService
*
service
.
TurnstileService
opsService
*
service
.
OpsService
soraS3Storage
*
service
.
SoraS3Storage
}
// NewSettingHandler 创建系统设置处理器
func
NewSettingHandler
(
settingService
*
service
.
SettingService
,
emailService
*
service
.
EmailService
,
turnstileService
*
service
.
TurnstileService
,
opsService
*
service
.
OpsService
)
*
SettingHandler
{
func
NewSettingHandler
(
settingService
*
service
.
SettingService
,
emailService
*
service
.
EmailService
,
turnstileService
*
service
.
TurnstileService
,
opsService
*
service
.
OpsService
,
soraS3Storage
*
service
.
SoraS3Storage
)
*
SettingHandler
{
return
&
SettingHandler
{
settingService
:
settingService
,
emailService
:
emailService
,
turnstileService
:
turnstileService
,
opsService
:
opsService
,
soraS3Storage
:
soraS3Storage
,
}
}
...
...
@@ -76,6 +79,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
SoraClientEnabled
:
settings
.
SoraClientEnabled
,
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
DefaultBalance
:
settings
.
DefaultBalance
,
EnableModelFallback
:
settings
.
EnableModelFallback
,
...
...
@@ -133,6 +137,7 @@ type UpdateSettingsRequest struct {
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
*
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
*
string
`json:"purchase_subscription_url"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
// 默认配置
DefaultConcurrency
int
`json:"default_concurrency"`
...
...
@@ -319,6 +324,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
HideCcsImportButton
:
req
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
purchaseEnabled
,
PurchaseSubscriptionURL
:
purchaseURL
,
SoraClientEnabled
:
req
.
SoraClientEnabled
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
DefaultBalance
:
req
.
DefaultBalance
,
EnableModelFallback
:
req
.
EnableModelFallback
,
...
...
@@ -400,6 +406,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
HideCcsImportButton
:
updatedSettings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
updatedSettings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
updatedSettings
.
PurchaseSubscriptionURL
,
SoraClientEnabled
:
updatedSettings
.
SoraClientEnabled
,
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
DefaultBalance
:
updatedSettings
.
DefaultBalance
,
EnableModelFallback
:
updatedSettings
.
EnableModelFallback
,
...
...
@@ -750,6 +757,384 @@ func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) {
})
}
func
toSoraS3SettingsDTO
(
settings
*
service
.
SoraS3Settings
)
dto
.
SoraS3Settings
{
if
settings
==
nil
{
return
dto
.
SoraS3Settings
{}
}
return
dto
.
SoraS3Settings
{
Enabled
:
settings
.
Enabled
,
Endpoint
:
settings
.
Endpoint
,
Region
:
settings
.
Region
,
Bucket
:
settings
.
Bucket
,
AccessKeyID
:
settings
.
AccessKeyID
,
SecretAccessKeyConfigured
:
settings
.
SecretAccessKeyConfigured
,
Prefix
:
settings
.
Prefix
,
ForcePathStyle
:
settings
.
ForcePathStyle
,
CDNURL
:
settings
.
CDNURL
,
DefaultStorageQuotaBytes
:
settings
.
DefaultStorageQuotaBytes
,
}
}
func
toSoraS3ProfileDTO
(
profile
service
.
SoraS3Profile
)
dto
.
SoraS3Profile
{
return
dto
.
SoraS3Profile
{
ProfileID
:
profile
.
ProfileID
,
Name
:
profile
.
Name
,
IsActive
:
profile
.
IsActive
,
Enabled
:
profile
.
Enabled
,
Endpoint
:
profile
.
Endpoint
,
Region
:
profile
.
Region
,
Bucket
:
profile
.
Bucket
,
AccessKeyID
:
profile
.
AccessKeyID
,
SecretAccessKeyConfigured
:
profile
.
SecretAccessKeyConfigured
,
Prefix
:
profile
.
Prefix
,
ForcePathStyle
:
profile
.
ForcePathStyle
,
CDNURL
:
profile
.
CDNURL
,
DefaultStorageQuotaBytes
:
profile
.
DefaultStorageQuotaBytes
,
UpdatedAt
:
profile
.
UpdatedAt
,
}
}
func
validateSoraS3RequiredWhenEnabled
(
enabled
bool
,
endpoint
,
bucket
,
accessKeyID
,
secretAccessKey
string
,
hasStoredSecret
bool
)
error
{
if
!
enabled
{
return
nil
}
if
strings
.
TrimSpace
(
endpoint
)
==
""
{
return
fmt
.
Errorf
(
"S3 Endpoint is required when enabled"
)
}
if
strings
.
TrimSpace
(
bucket
)
==
""
{
return
fmt
.
Errorf
(
"S3 Bucket is required when enabled"
)
}
if
strings
.
TrimSpace
(
accessKeyID
)
==
""
{
return
fmt
.
Errorf
(
"S3 Access Key ID is required when enabled"
)
}
if
strings
.
TrimSpace
(
secretAccessKey
)
!=
""
||
hasStoredSecret
{
return
nil
}
return
fmt
.
Errorf
(
"S3 Secret Access Key is required when enabled"
)
}
func
findSoraS3ProfileByID
(
items
[]
service
.
SoraS3Profile
,
profileID
string
)
*
service
.
SoraS3Profile
{
for
idx
:=
range
items
{
if
items
[
idx
]
.
ProfileID
==
profileID
{
return
&
items
[
idx
]
}
}
return
nil
}
// GetSoraS3Settings 获取 Sora S3 存储配置(兼容旧单配置接口)
// GET /api/v1/admin/settings/sora-s3
func
(
h
*
SettingHandler
)
GetSoraS3Settings
(
c
*
gin
.
Context
)
{
settings
,
err
:=
h
.
settingService
.
GetSoraS3Settings
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
toSoraS3SettingsDTO
(
settings
))
}
// ListSoraS3Profiles 获取 Sora S3 多配置
// GET /api/v1/admin/settings/sora-s3/profiles
func
(
h
*
SettingHandler
)
ListSoraS3Profiles
(
c
*
gin
.
Context
)
{
result
,
err
:=
h
.
settingService
.
ListSoraS3Profiles
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
items
:=
make
([]
dto
.
SoraS3Profile
,
0
,
len
(
result
.
Items
))
for
idx
:=
range
result
.
Items
{
items
=
append
(
items
,
toSoraS3ProfileDTO
(
result
.
Items
[
idx
]))
}
response
.
Success
(
c
,
dto
.
ListSoraS3ProfilesResponse
{
ActiveProfileID
:
result
.
ActiveProfileID
,
Items
:
items
,
})
}
// UpdateSoraS3SettingsRequest 更新/测试 Sora S3 配置请求(兼容旧接口)
type
UpdateSoraS3SettingsRequest
struct
{
ProfileID
string
`json:"profile_id"`
Enabled
bool
`json:"enabled"`
Endpoint
string
`json:"endpoint"`
Region
string
`json:"region"`
Bucket
string
`json:"bucket"`
AccessKeyID
string
`json:"access_key_id"`
SecretAccessKey
string
`json:"secret_access_key"`
Prefix
string
`json:"prefix"`
ForcePathStyle
bool
`json:"force_path_style"`
CDNURL
string
`json:"cdn_url"`
DefaultStorageQuotaBytes
int64
`json:"default_storage_quota_bytes"`
}
type
CreateSoraS3ProfileRequest
struct
{
ProfileID
string
`json:"profile_id"`
Name
string
`json:"name"`
SetActive
bool
`json:"set_active"`
Enabled
bool
`json:"enabled"`
Endpoint
string
`json:"endpoint"`
Region
string
`json:"region"`
Bucket
string
`json:"bucket"`
AccessKeyID
string
`json:"access_key_id"`
SecretAccessKey
string
`json:"secret_access_key"`
Prefix
string
`json:"prefix"`
ForcePathStyle
bool
`json:"force_path_style"`
CDNURL
string
`json:"cdn_url"`
DefaultStorageQuotaBytes
int64
`json:"default_storage_quota_bytes"`
}
type
UpdateSoraS3ProfileRequest
struct
{
Name
string
`json:"name"`
Enabled
bool
`json:"enabled"`
Endpoint
string
`json:"endpoint"`
Region
string
`json:"region"`
Bucket
string
`json:"bucket"`
AccessKeyID
string
`json:"access_key_id"`
SecretAccessKey
string
`json:"secret_access_key"`
Prefix
string
`json:"prefix"`
ForcePathStyle
bool
`json:"force_path_style"`
CDNURL
string
`json:"cdn_url"`
DefaultStorageQuotaBytes
int64
`json:"default_storage_quota_bytes"`
}
// CreateSoraS3Profile 创建 Sora S3 配置
// POST /api/v1/admin/settings/sora-s3/profiles
func
(
h
*
SettingHandler
)
CreateSoraS3Profile
(
c
*
gin
.
Context
)
{
var
req
CreateSoraS3ProfileRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
req
.
DefaultStorageQuotaBytes
<
0
{
req
.
DefaultStorageQuotaBytes
=
0
}
if
strings
.
TrimSpace
(
req
.
Name
)
==
""
{
response
.
BadRequest
(
c
,
"Name is required"
)
return
}
if
strings
.
TrimSpace
(
req
.
ProfileID
)
==
""
{
response
.
BadRequest
(
c
,
"Profile ID is required"
)
return
}
if
err
:=
validateSoraS3RequiredWhenEnabled
(
req
.
Enabled
,
req
.
Endpoint
,
req
.
Bucket
,
req
.
AccessKeyID
,
req
.
SecretAccessKey
,
false
);
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
created
,
err
:=
h
.
settingService
.
CreateSoraS3Profile
(
c
.
Request
.
Context
(),
&
service
.
SoraS3Profile
{
ProfileID
:
req
.
ProfileID
,
Name
:
req
.
Name
,
Enabled
:
req
.
Enabled
,
Endpoint
:
req
.
Endpoint
,
Region
:
req
.
Region
,
Bucket
:
req
.
Bucket
,
AccessKeyID
:
req
.
AccessKeyID
,
SecretAccessKey
:
req
.
SecretAccessKey
,
Prefix
:
req
.
Prefix
,
ForcePathStyle
:
req
.
ForcePathStyle
,
CDNURL
:
req
.
CDNURL
,
DefaultStorageQuotaBytes
:
req
.
DefaultStorageQuotaBytes
,
},
req
.
SetActive
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
toSoraS3ProfileDTO
(
*
created
))
}
// UpdateSoraS3Profile 更新 Sora S3 配置
// PUT /api/v1/admin/settings/sora-s3/profiles/:profile_id
func
(
h
*
SettingHandler
)
UpdateSoraS3Profile
(
c
*
gin
.
Context
)
{
profileID
:=
strings
.
TrimSpace
(
c
.
Param
(
"profile_id"
))
if
profileID
==
""
{
response
.
BadRequest
(
c
,
"Profile ID is required"
)
return
}
var
req
UpdateSoraS3ProfileRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
req
.
DefaultStorageQuotaBytes
<
0
{
req
.
DefaultStorageQuotaBytes
=
0
}
if
strings
.
TrimSpace
(
req
.
Name
)
==
""
{
response
.
BadRequest
(
c
,
"Name is required"
)
return
}
existingList
,
err
:=
h
.
settingService
.
ListSoraS3Profiles
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
existing
:=
findSoraS3ProfileByID
(
existingList
.
Items
,
profileID
)
if
existing
==
nil
{
response
.
ErrorFrom
(
c
,
service
.
ErrSoraS3ProfileNotFound
)
return
}
if
err
:=
validateSoraS3RequiredWhenEnabled
(
req
.
Enabled
,
req
.
Endpoint
,
req
.
Bucket
,
req
.
AccessKeyID
,
req
.
SecretAccessKey
,
existing
.
SecretAccessKeyConfigured
);
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
updated
,
updateErr
:=
h
.
settingService
.
UpdateSoraS3Profile
(
c
.
Request
.
Context
(),
profileID
,
&
service
.
SoraS3Profile
{
Name
:
req
.
Name
,
Enabled
:
req
.
Enabled
,
Endpoint
:
req
.
Endpoint
,
Region
:
req
.
Region
,
Bucket
:
req
.
Bucket
,
AccessKeyID
:
req
.
AccessKeyID
,
SecretAccessKey
:
req
.
SecretAccessKey
,
Prefix
:
req
.
Prefix
,
ForcePathStyle
:
req
.
ForcePathStyle
,
CDNURL
:
req
.
CDNURL
,
DefaultStorageQuotaBytes
:
req
.
DefaultStorageQuotaBytes
,
})
if
updateErr
!=
nil
{
response
.
ErrorFrom
(
c
,
updateErr
)
return
}
response
.
Success
(
c
,
toSoraS3ProfileDTO
(
*
updated
))
}
// DeleteSoraS3Profile 删除 Sora S3 配置
// DELETE /api/v1/admin/settings/sora-s3/profiles/:profile_id
func
(
h
*
SettingHandler
)
DeleteSoraS3Profile
(
c
*
gin
.
Context
)
{
profileID
:=
strings
.
TrimSpace
(
c
.
Param
(
"profile_id"
))
if
profileID
==
""
{
response
.
BadRequest
(
c
,
"Profile ID is required"
)
return
}
if
err
:=
h
.
settingService
.
DeleteSoraS3Profile
(
c
.
Request
.
Context
(),
profileID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"deleted"
:
true
})
}
// SetActiveSoraS3Profile 切换激活 Sora S3 配置
// POST /api/v1/admin/settings/sora-s3/profiles/:profile_id/activate
func
(
h
*
SettingHandler
)
SetActiveSoraS3Profile
(
c
*
gin
.
Context
)
{
profileID
:=
strings
.
TrimSpace
(
c
.
Param
(
"profile_id"
))
if
profileID
==
""
{
response
.
BadRequest
(
c
,
"Profile ID is required"
)
return
}
active
,
err
:=
h
.
settingService
.
SetActiveSoraS3Profile
(
c
.
Request
.
Context
(),
profileID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
toSoraS3ProfileDTO
(
*
active
))
}
// UpdateSoraS3Settings 更新 Sora S3 存储配置(兼容旧单配置接口)
// PUT /api/v1/admin/settings/sora-s3
func
(
h
*
SettingHandler
)
UpdateSoraS3Settings
(
c
*
gin
.
Context
)
{
var
req
UpdateSoraS3SettingsRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
existing
,
err
:=
h
.
settingService
.
GetSoraS3Settings
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
if
req
.
DefaultStorageQuotaBytes
<
0
{
req
.
DefaultStorageQuotaBytes
=
0
}
if
err
:=
validateSoraS3RequiredWhenEnabled
(
req
.
Enabled
,
req
.
Endpoint
,
req
.
Bucket
,
req
.
AccessKeyID
,
req
.
SecretAccessKey
,
existing
.
SecretAccessKeyConfigured
);
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
settings
:=
&
service
.
SoraS3Settings
{
Enabled
:
req
.
Enabled
,
Endpoint
:
req
.
Endpoint
,
Region
:
req
.
Region
,
Bucket
:
req
.
Bucket
,
AccessKeyID
:
req
.
AccessKeyID
,
SecretAccessKey
:
req
.
SecretAccessKey
,
Prefix
:
req
.
Prefix
,
ForcePathStyle
:
req
.
ForcePathStyle
,
CDNURL
:
req
.
CDNURL
,
DefaultStorageQuotaBytes
:
req
.
DefaultStorageQuotaBytes
,
}
if
err
:=
h
.
settingService
.
SetSoraS3Settings
(
c
.
Request
.
Context
(),
settings
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
updatedSettings
,
err
:=
h
.
settingService
.
GetSoraS3Settings
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
toSoraS3SettingsDTO
(
updatedSettings
))
}
// TestSoraS3Connection 测试 Sora S3 连接(HeadBucket)
// POST /api/v1/admin/settings/sora-s3/test
func
(
h
*
SettingHandler
)
TestSoraS3Connection
(
c
*
gin
.
Context
)
{
if
h
.
soraS3Storage
==
nil
{
response
.
Error
(
c
,
500
,
"S3 存储服务未初始化"
)
return
}
var
req
UpdateSoraS3SettingsRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
!
req
.
Enabled
{
response
.
BadRequest
(
c
,
"S3 未启用,无法测试连接"
)
return
}
if
req
.
SecretAccessKey
==
""
{
if
req
.
ProfileID
!=
""
{
profiles
,
err
:=
h
.
settingService
.
ListSoraS3Profiles
(
c
.
Request
.
Context
())
if
err
==
nil
{
profile
:=
findSoraS3ProfileByID
(
profiles
.
Items
,
req
.
ProfileID
)
if
profile
!=
nil
{
req
.
SecretAccessKey
=
profile
.
SecretAccessKey
}
}
}
if
req
.
SecretAccessKey
==
""
{
existing
,
err
:=
h
.
settingService
.
GetSoraS3Settings
(
c
.
Request
.
Context
())
if
err
==
nil
{
req
.
SecretAccessKey
=
existing
.
SecretAccessKey
}
}
}
testCfg
:=
&
service
.
SoraS3Settings
{
Enabled
:
true
,
Endpoint
:
req
.
Endpoint
,
Region
:
req
.
Region
,
Bucket
:
req
.
Bucket
,
AccessKeyID
:
req
.
AccessKeyID
,
SecretAccessKey
:
req
.
SecretAccessKey
,
Prefix
:
req
.
Prefix
,
ForcePathStyle
:
req
.
ForcePathStyle
,
CDNURL
:
req
.
CDNURL
,
}
if
err
:=
h
.
soraS3Storage
.
TestConnectionWithSettings
(
c
.
Request
.
Context
(),
testCfg
);
err
!=
nil
{
response
.
Error
(
c
,
400
,
"S3 连接测试失败: "
+
err
.
Error
())
return
}
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"S3 连接成功"
})
}
// UpdateStreamTimeoutSettingsRequest 更新流超时配置请求
type
UpdateStreamTimeoutSettingsRequest
struct
{
Enabled
bool
`json:"enabled"`
...
...
backend/internal/handler/admin/usage_cleanup_handler_test.go
View file @
bb664d9b
...
...
@@ -225,6 +225,92 @@ func TestUsageHandlerCreateCleanupTaskInvalidEndDate(t *testing.T) {
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskInvalidRequestType
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
88
)
payload
:=
map
[
string
]
any
{
"start_date"
:
"2024-01-01"
,
"end_date"
:
"2024-01-02"
,
"timezone"
:
"UTC"
,
"request_type"
:
"invalid"
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskRequestTypePriority
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
99
)
payload
:=
map
[
string
]
any
{
"start_date"
:
"2024-01-01"
,
"end_date"
:
"2024-01-02"
,
"timezone"
:
"UTC"
,
"request_type"
:
"ws_v2"
,
"stream"
:
false
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Len
(
t
,
repo
.
created
,
1
)
created
:=
repo
.
created
[
0
]
require
.
NotNil
(
t
,
created
.
Filters
.
RequestType
)
require
.
Equal
(
t
,
int16
(
service
.
RequestTypeWSV2
),
*
created
.
Filters
.
RequestType
)
require
.
Nil
(
t
,
created
.
Filters
.
Stream
)
}
func
TestUsageHandlerCreateCleanupTaskWithLegacyStream
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
99
)
payload
:=
map
[
string
]
any
{
"start_date"
:
"2024-01-01"
,
"end_date"
:
"2024-01-02"
,
"timezone"
:
"UTC"
,
"stream"
:
true
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Len
(
t
,
repo
.
created
,
1
)
created
:=
repo
.
created
[
0
]
require
.
Nil
(
t
,
created
.
Filters
.
RequestType
)
require
.
NotNil
(
t
,
created
.
Filters
.
Stream
)
require
.
True
(
t
,
*
created
.
Filters
.
Stream
)
}
func
TestUsageHandlerCreateCleanupTaskSuccess
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
...
...
backend/internal/handler/admin/usage_handler.go
View file @
bb664d9b
...
...
@@ -51,6 +51,7 @@ type CreateUsageCleanupTaskRequest struct {
AccountID
*
int64
`json:"account_id"`
GroupID
*
int64
`json:"group_id"`
Model
*
string
`json:"model"`
RequestType
*
string
`json:"request_type"`
Stream
*
bool
`json:"stream"`
BillingType
*
int8
`json:"billing_type"`
Timezone
string
`json:"timezone"`
...
...
@@ -101,8 +102,17 @@ func (h *UsageHandler) List(c *gin.Context) {
model
:=
c
.
Query
(
"model"
)
var
requestType
*
int16
var
stream
*
bool
if
streamStr
:=
c
.
Query
(
"stream"
);
streamStr
!=
""
{
if
requestTypeStr
:=
strings
.
TrimSpace
(
c
.
Query
(
"request_type"
));
requestTypeStr
!=
""
{
parsed
,
err
:=
service
.
ParseUsageRequestType
(
requestTypeStr
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
value
:=
int16
(
parsed
)
requestType
=
&
value
}
else
if
streamStr
:=
c
.
Query
(
"stream"
);
streamStr
!=
""
{
val
,
err
:=
strconv
.
ParseBool
(
streamStr
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid stream value, use true or false"
)
...
...
@@ -152,6 +162,7 @@ func (h *UsageHandler) List(c *gin.Context) {
AccountID
:
accountID
,
GroupID
:
groupID
,
Model
:
model
,
RequestType
:
requestType
,
Stream
:
stream
,
BillingType
:
billingType
,
StartTime
:
startTime
,
...
...
@@ -214,8 +225,17 @@ func (h *UsageHandler) Stats(c *gin.Context) {
model
:=
c
.
Query
(
"model"
)
var
requestType
*
int16
var
stream
*
bool
if
streamStr
:=
c
.
Query
(
"stream"
);
streamStr
!=
""
{
if
requestTypeStr
:=
strings
.
TrimSpace
(
c
.
Query
(
"request_type"
));
requestTypeStr
!=
""
{
parsed
,
err
:=
service
.
ParseUsageRequestType
(
requestTypeStr
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
value
:=
int16
(
parsed
)
requestType
=
&
value
}
else
if
streamStr
:=
c
.
Query
(
"stream"
);
streamStr
!=
""
{
val
,
err
:=
strconv
.
ParseBool
(
streamStr
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid stream value, use true or false"
)
...
...
@@ -278,6 +298,7 @@ func (h *UsageHandler) Stats(c *gin.Context) {
AccountID
:
accountID
,
GroupID
:
groupID
,
Model
:
model
,
RequestType
:
requestType
,
Stream
:
stream
,
BillingType
:
billingType
,
StartTime
:
&
startTime
,
...
...
@@ -432,6 +453,19 @@ func (h *UsageHandler) CreateCleanupTask(c *gin.Context) {
}
endTime
=
endTime
.
Add
(
24
*
time
.
Hour
-
time
.
Nanosecond
)
var
requestType
*
int16
stream
:=
req
.
Stream
if
req
.
RequestType
!=
nil
{
parsed
,
err
:=
service
.
ParseUsageRequestType
(
*
req
.
RequestType
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
value
:=
int16
(
parsed
)
requestType
=
&
value
stream
=
nil
}
filters
:=
service
.
UsageCleanupFilters
{
StartTime
:
startTime
,
EndTime
:
endTime
,
...
...
@@ -440,7 +474,8 @@ func (h *UsageHandler) CreateCleanupTask(c *gin.Context) {
AccountID
:
req
.
AccountID
,
GroupID
:
req
.
GroupID
,
Model
:
req
.
Model
,
Stream
:
req
.
Stream
,
RequestType
:
requestType
,
Stream
:
stream
,
BillingType
:
req
.
BillingType
,
}
...
...
@@ -464,9 +499,13 @@ func (h *UsageHandler) CreateCleanupTask(c *gin.Context) {
if
filters
.
Model
!=
nil
{
model
=
*
filters
.
Model
}
var
stream
any
var
stream
Value
any
if
filters
.
Stream
!=
nil
{
stream
=
*
filters
.
Stream
streamValue
=
*
filters
.
Stream
}
var
requestTypeName
any
if
filters
.
RequestType
!=
nil
{
requestTypeName
=
service
.
RequestTypeFromInt16
(
*
filters
.
RequestType
)
.
String
()
}
var
billingType
any
if
filters
.
BillingType
!=
nil
{
...
...
@@ -481,7 +520,7 @@ func (h *UsageHandler) CreateCleanupTask(c *gin.Context) {
Body
:
req
,
}
executeAdminIdempotentJSON
(
c
,
"admin.usage.cleanup_tasks.create"
,
idempotencyPayload
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
logger
.
LegacyPrintf
(
"handler.admin.usage"
,
"[UsageCleanup] 请求创建清理任务: operator=%d start=%s end=%s user_id=%v api_key_id=%v account_id=%v group_id=%v model=%v stream=%v billing_type=%v tz=%q"
,
logger
.
LegacyPrintf
(
"handler.admin.usage"
,
"[UsageCleanup] 请求创建清理任务: operator=%d start=%s end=%s user_id=%v api_key_id=%v account_id=%v group_id=%v model=%v
request_type=%v
stream=%v billing_type=%v tz=%q"
,
subject
.
UserID
,
filters
.
StartTime
.
Format
(
time
.
RFC3339
),
filters
.
EndTime
.
Format
(
time
.
RFC3339
),
...
...
@@ -490,7 +529,8 @@ func (h *UsageHandler) CreateCleanupTask(c *gin.Context) {
accountID
,
groupID
,
model
,
stream
,
requestTypeName
,
streamValue
,
billingType
,
req
.
Timezone
,
)
...
...
backend/internal/handler/admin/usage_handler_request_type_test.go
0 → 100644
View file @
bb664d9b
package
admin
import
(
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type
adminUsageRepoCapture
struct
{
service
.
UsageLogRepository
listFilters
usagestats
.
UsageLogFilters
statsFilters
usagestats
.
UsageLogFilters
}
func
(
s
*
adminUsageRepoCapture
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
usagestats
.
UsageLogFilters
)
([]
service
.
UsageLog
,
*
pagination
.
PaginationResult
,
error
)
{
s
.
listFilters
=
filters
return
[]
service
.
UsageLog
{},
&
pagination
.
PaginationResult
{
Total
:
0
,
Page
:
params
.
Page
,
PageSize
:
params
.
PageSize
,
Pages
:
0
,
},
nil
}
func
(
s
*
adminUsageRepoCapture
)
GetStatsWithFilters
(
ctx
context
.
Context
,
filters
usagestats
.
UsageLogFilters
)
(
*
usagestats
.
UsageStats
,
error
)
{
s
.
statsFilters
=
filters
return
&
usagestats
.
UsageStats
{},
nil
}
func
newAdminUsageRequestTypeTestRouter
(
repo
*
adminUsageRepoCapture
)
*
gin
.
Engine
{
gin
.
SetMode
(
gin
.
TestMode
)
usageSvc
:=
service
.
NewUsageService
(
repo
,
nil
,
nil
,
nil
)
handler
:=
NewUsageHandler
(
usageSvc
,
nil
,
nil
,
nil
)
router
:=
gin
.
New
()
router
.
GET
(
"/admin/usage"
,
handler
.
List
)
router
.
GET
(
"/admin/usage/stats"
,
handler
.
Stats
)
return
router
}
func
TestAdminUsageListRequestTypePriority
(
t
*
testing
.
T
)
{
repo
:=
&
adminUsageRepoCapture
{}
router
:=
newAdminUsageRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/usage?request_type=ws_v2&stream=false"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
NotNil
(
t
,
repo
.
listFilters
.
RequestType
)
require
.
Equal
(
t
,
int16
(
service
.
RequestTypeWSV2
),
*
repo
.
listFilters
.
RequestType
)
require
.
Nil
(
t
,
repo
.
listFilters
.
Stream
)
}
func
TestAdminUsageListInvalidRequestType
(
t
*
testing
.
T
)
{
repo
:=
&
adminUsageRepoCapture
{}
router
:=
newAdminUsageRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/usage?request_type=bad"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
rec
.
Code
)
}
func
TestAdminUsageListInvalidStream
(
t
*
testing
.
T
)
{
repo
:=
&
adminUsageRepoCapture
{}
router
:=
newAdminUsageRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/usage?stream=bad"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
rec
.
Code
)
}
func
TestAdminUsageStatsRequestTypePriority
(
t
*
testing
.
T
)
{
repo
:=
&
adminUsageRepoCapture
{}
router
:=
newAdminUsageRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/usage/stats?request_type=stream&stream=bad"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
NotNil
(
t
,
repo
.
statsFilters
.
RequestType
)
require
.
Equal
(
t
,
int16
(
service
.
RequestTypeStream
),
*
repo
.
statsFilters
.
RequestType
)
require
.
Nil
(
t
,
repo
.
statsFilters
.
Stream
)
}
func
TestAdminUsageStatsInvalidRequestType
(
t
*
testing
.
T
)
{
repo
:=
&
adminUsageRepoCapture
{}
router
:=
newAdminUsageRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/usage/stats?request_type=oops"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
rec
.
Code
)
}
func
TestAdminUsageStatsInvalidStream
(
t
*
testing
.
T
)
{
repo
:=
&
adminUsageRepoCapture
{}
router
:=
newAdminUsageRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/usage/stats?stream=oops"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
rec
.
Code
)
}
backend/internal/handler/admin/user_handler.go
View file @
bb664d9b
...
...
@@ -41,6 +41,7 @@ type CreateUserRequest struct {
Balance
float64
`json:"balance"`
Concurrency
int
`json:"concurrency"`
AllowedGroups
[]
int64
`json:"allowed_groups"`
SoraStorageQuotaBytes
int64
`json:"sora_storage_quota_bytes"`
}
// UpdateUserRequest represents admin update user request
...
...
@@ -57,6 +58,7 @@ type UpdateUserRequest struct {
// GroupRates 用户专属分组倍率配置
// map[groupID]*rate,nil 表示删除该分组的专属倍率
GroupRates
map
[
int64
]
*
float64
`json:"group_rates"`
SoraStorageQuotaBytes
*
int64
`json:"sora_storage_quota_bytes"`
}
// UpdateBalanceRequest represents balance update request
...
...
@@ -181,6 +183,7 @@ func (h *UserHandler) Create(c *gin.Context) {
Balance
:
req
.
Balance
,
Concurrency
:
req
.
Concurrency
,
AllowedGroups
:
req
.
AllowedGroups
,
SoraStorageQuotaBytes
:
req
.
SoraStorageQuotaBytes
,
})
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
...
...
@@ -216,6 +219,7 @@ func (h *UserHandler) Update(c *gin.Context) {
Status
:
req
.
Status
,
AllowedGroups
:
req
.
AllowedGroups
,
GroupRates
:
req
.
GroupRates
,
SoraStorageQuotaBytes
:
req
.
SoraStorageQuotaBytes
,
})
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
...
...
backend/internal/handler/auth_handler.go
View file @
bb664d9b
...
...
@@ -113,9 +113,8 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
// Turnstile 验证 — 始终执行,防止绕过
// TODO: 确认前端在提交邮箱验证码注册时也传递了 turnstile_token
if
err
:=
h
.
authService
.
VerifyTurnstile
(
c
.
Request
.
Context
(),
req
.
TurnstileToken
,
ip
.
GetClientIP
(
c
));
err
!=
nil
{
// Turnstile 验证(邮箱验证码注册场景避免重复校验一次性 token)
if
err
:=
h
.
authService
.
VerifyTurnstileForRegister
(
c
.
Request
.
Context
(),
req
.
TurnstileToken
,
ip
.
GetClientIP
(
c
),
req
.
VerifyCode
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
...
...
backend/internal/handler/dto/mappers.go
View file @
bb664d9b
...
...
@@ -62,6 +62,8 @@ func UserFromServiceAdmin(u *service.User) *AdminUser {
User
:
*
base
,
Notes
:
u
.
Notes
,
GroupRates
:
u
.
GroupRates
,
SoraStorageQuotaBytes
:
u
.
SoraStorageQuotaBytes
,
SoraStorageUsedBytes
:
u
.
SoraStorageUsedBytes
,
}
}
...
...
@@ -152,6 +154,7 @@ func groupFromServiceBase(g *service.Group) Group {
ClaudeCodeOnly
:
g
.
ClaudeCodeOnly
,
FallbackGroupID
:
g
.
FallbackGroupID
,
FallbackGroupIDOnInvalidRequest
:
g
.
FallbackGroupIDOnInvalidRequest
,
SoraStorageQuotaBytes
:
g
.
SoraStorageQuotaBytes
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
}
...
...
@@ -385,6 +388,8 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary {
func
usageLogFromServiceUser
(
l
*
service
.
UsageLog
)
UsageLog
{
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。
requestType
:=
l
.
EffectiveRequestType
()
stream
,
openAIWSMode
:=
service
.
ApplyLegacyRequestFields
(
requestType
,
l
.
Stream
,
l
.
OpenAIWSMode
)
return
UsageLog
{
ID
:
l
.
ID
,
UserID
:
l
.
UserID
,
...
...
@@ -409,7 +414,9 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ActualCost
:
l
.
ActualCost
,
RateMultiplier
:
l
.
RateMultiplier
,
BillingType
:
l
.
BillingType
,
Stream
:
l
.
Stream
,
RequestType
:
requestType
.
String
(),
Stream
:
stream
,
OpenAIWSMode
:
openAIWSMode
,
DurationMs
:
l
.
DurationMs
,
FirstTokenMs
:
l
.
FirstTokenMs
,
ImageCount
:
l
.
ImageCount
,
...
...
@@ -464,6 +471,7 @@ func UsageCleanupTaskFromService(task *service.UsageCleanupTask) *UsageCleanupTa
AccountID
:
task
.
Filters
.
AccountID
,
GroupID
:
task
.
Filters
.
GroupID
,
Model
:
task
.
Filters
.
Model
,
RequestType
:
requestTypeStringPtr
(
task
.
Filters
.
RequestType
),
Stream
:
task
.
Filters
.
Stream
,
BillingType
:
task
.
Filters
.
BillingType
,
},
...
...
@@ -479,6 +487,14 @@ func UsageCleanupTaskFromService(task *service.UsageCleanupTask) *UsageCleanupTa
}
}
func
requestTypeStringPtr
(
requestType
*
int16
)
*
string
{
if
requestType
==
nil
{
return
nil
}
value
:=
service
.
RequestTypeFromInt16
(
*
requestType
)
.
String
()
return
&
value
}
func
SettingFromService
(
s
*
service
.
Setting
)
*
Setting
{
if
s
==
nil
{
return
nil
...
...
backend/internal/handler/dto/mappers_usage_test.go
0 → 100644
View file @
bb664d9b
package
dto
import
(
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func
TestUsageLogFromService_IncludesOpenAIWSMode
(
t
*
testing
.
T
)
{
t
.
Parallel
()
wsLog
:=
&
service
.
UsageLog
{
RequestID
:
"req_1"
,
Model
:
"gpt-5.3-codex"
,
OpenAIWSMode
:
true
,
}
httpLog
:=
&
service
.
UsageLog
{
RequestID
:
"resp_1"
,
Model
:
"gpt-5.3-codex"
,
OpenAIWSMode
:
false
,
}
require
.
True
(
t
,
UsageLogFromService
(
wsLog
)
.
OpenAIWSMode
)
require
.
False
(
t
,
UsageLogFromService
(
httpLog
)
.
OpenAIWSMode
)
require
.
True
(
t
,
UsageLogFromServiceAdmin
(
wsLog
)
.
OpenAIWSMode
)
require
.
False
(
t
,
UsageLogFromServiceAdmin
(
httpLog
)
.
OpenAIWSMode
)
}
func
TestUsageLogFromService_PrefersRequestTypeForLegacyFields
(
t
*
testing
.
T
)
{
t
.
Parallel
()
log
:=
&
service
.
UsageLog
{
RequestID
:
"req_2"
,
Model
:
"gpt-5.3-codex"
,
RequestType
:
service
.
RequestTypeWSV2
,
Stream
:
false
,
OpenAIWSMode
:
false
,
}
userDTO
:=
UsageLogFromService
(
log
)
adminDTO
:=
UsageLogFromServiceAdmin
(
log
)
require
.
Equal
(
t
,
"ws_v2"
,
userDTO
.
RequestType
)
require
.
True
(
t
,
userDTO
.
Stream
)
require
.
True
(
t
,
userDTO
.
OpenAIWSMode
)
require
.
Equal
(
t
,
"ws_v2"
,
adminDTO
.
RequestType
)
require
.
True
(
t
,
adminDTO
.
Stream
)
require
.
True
(
t
,
adminDTO
.
OpenAIWSMode
)
}
func
TestUsageCleanupTaskFromService_RequestTypeMapping
(
t
*
testing
.
T
)
{
t
.
Parallel
()
requestType
:=
int16
(
service
.
RequestTypeStream
)
task
:=
&
service
.
UsageCleanupTask
{
ID
:
1
,
Status
:
service
.
UsageCleanupStatusPending
,
Filters
:
service
.
UsageCleanupFilters
{
RequestType
:
&
requestType
,
},
}
dtoTask
:=
UsageCleanupTaskFromService
(
task
)
require
.
NotNil
(
t
,
dtoTask
)
require
.
NotNil
(
t
,
dtoTask
.
Filters
.
RequestType
)
require
.
Equal
(
t
,
"stream"
,
*
dtoTask
.
Filters
.
RequestType
)
}
func
TestRequestTypeStringPtrNil
(
t
*
testing
.
T
)
{
t
.
Parallel
()
require
.
Nil
(
t
,
requestTypeStringPtr
(
nil
))
}
backend/internal/handler/dto/settings.go
View file @
bb664d9b
...
...
@@ -37,6 +37,7 @@ type SystemSettings struct {
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
...
...
@@ -79,9 +80,48 @@ type PublicSettings struct {
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
Version
string
`json:"version"`
}
// SoraS3Settings Sora S3 存储配置 DTO(响应用,不含敏感字段)
type
SoraS3Settings
struct
{
Enabled
bool
`json:"enabled"`
Endpoint
string
`json:"endpoint"`
Region
string
`json:"region"`
Bucket
string
`json:"bucket"`
AccessKeyID
string
`json:"access_key_id"`
SecretAccessKeyConfigured
bool
`json:"secret_access_key_configured"`
Prefix
string
`json:"prefix"`
ForcePathStyle
bool
`json:"force_path_style"`
CDNURL
string
`json:"cdn_url"`
DefaultStorageQuotaBytes
int64
`json:"default_storage_quota_bytes"`
}
// SoraS3Profile Sora S3 存储配置项 DTO(响应用,不含敏感字段)
type
SoraS3Profile
struct
{
ProfileID
string
`json:"profile_id"`
Name
string
`json:"name"`
IsActive
bool
`json:"is_active"`
Enabled
bool
`json:"enabled"`
Endpoint
string
`json:"endpoint"`
Region
string
`json:"region"`
Bucket
string
`json:"bucket"`
AccessKeyID
string
`json:"access_key_id"`
SecretAccessKeyConfigured
bool
`json:"secret_access_key_configured"`
Prefix
string
`json:"prefix"`
ForcePathStyle
bool
`json:"force_path_style"`
CDNURL
string
`json:"cdn_url"`
DefaultStorageQuotaBytes
int64
`json:"default_storage_quota_bytes"`
UpdatedAt
string
`json:"updated_at"`
}
// ListSoraS3ProfilesResponse Sora S3 配置列表响应
type
ListSoraS3ProfilesResponse
struct
{
ActiveProfileID
string
`json:"active_profile_id"`
Items
[]
SoraS3Profile
`json:"items"`
}
// StreamTimeoutSettings 流超时处理配置 DTO
type
StreamTimeoutSettings
struct
{
Enabled
bool
`json:"enabled"`
...
...
backend/internal/handler/dto/types.go
View file @
bb664d9b
...
...
@@ -27,6 +27,8 @@ type AdminUser struct {
// GroupRates 用户专属分组倍率配置
// map[groupID]rateMultiplier
GroupRates
map
[
int64
]
float64
`json:"group_rates,omitempty"`
SoraStorageQuotaBytes
int64
`json:"sora_storage_quota_bytes"`
SoraStorageUsedBytes
int64
`json:"sora_storage_used_bytes"`
}
type
APIKey
struct
{
...
...
@@ -80,6 +82,9 @@ type Group struct {
// 无效请求兜底分组
FallbackGroupIDOnInvalidRequest
*
int64
`json:"fallback_group_id_on_invalid_request"`
// Sora 存储配额
SoraStorageQuotaBytes
int64
`json:"sora_storage_quota_bytes"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
...
...
@@ -279,7 +284,9 @@ type UsageLog struct {
RateMultiplier
float64
`json:"rate_multiplier"`
BillingType
int8
`json:"billing_type"`
RequestType
string
`json:"request_type"`
Stream
bool
`json:"stream"`
OpenAIWSMode
bool
`json:"openai_ws_mode"`
DurationMs
*
int
`json:"duration_ms"`
FirstTokenMs
*
int
`json:"first_token_ms"`
...
...
@@ -324,6 +331,7 @@ type UsageCleanupFilters struct {
AccountID
*
int64
`json:"account_id,omitempty"`
GroupID
*
int64
`json:"group_id,omitempty"`
Model
*
string
`json:"model,omitempty"`
RequestType
*
string
`json:"request_type,omitempty"`
Stream
*
bool
`json:"stream,omitempty"`
BillingType
*
int8
`json:"billing_type,omitempty"`
}
...
...
backend/internal/handler/failover_loop.go
View file @
bb664d9b
...
...
@@ -2,11 +2,12 @@ package handler
import
(
"context"
"log"
"net/http"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/service"
"go.uber.org/zap"
)
// TempUnscheduler 用于 HandleFailoverError 中同账号重试耗尽后的临时封禁。
...
...
@@ -78,8 +79,12 @@ func (s *FailoverState) HandleFailoverError(
// 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试
if
failoverErr
.
RetryableOnSameAccount
&&
s
.
SameAccountRetryCount
[
accountID
]
<
maxSameAccountRetries
{
s
.
SameAccountRetryCount
[
accountID
]
++
log
.
Printf
(
"Account %d: retryable error %d, same-account retry %d/%d"
,
accountID
,
failoverErr
.
StatusCode
,
s
.
SameAccountRetryCount
[
accountID
],
maxSameAccountRetries
)
logger
.
FromContext
(
ctx
)
.
Warn
(
"gateway.failover_same_account_retry"
,
zap
.
Int64
(
"account_id"
,
accountID
),
zap
.
Int
(
"upstream_status"
,
failoverErr
.
StatusCode
),
zap
.
Int
(
"same_account_retry_count"
,
s
.
SameAccountRetryCount
[
accountID
]),
zap
.
Int
(
"same_account_retry_max"
,
maxSameAccountRetries
),
)
if
!
sleepWithContext
(
ctx
,
sameAccountRetryDelay
)
{
return
FailoverCanceled
}
...
...
@@ -101,8 +106,12 @@ func (s *FailoverState) HandleFailoverError(
// 递增切换计数
s
.
SwitchCount
++
log
.
Printf
(
"Account %d: upstream error %d, switching account %d/%d"
,
accountID
,
failoverErr
.
StatusCode
,
s
.
SwitchCount
,
s
.
MaxSwitches
)
logger
.
FromContext
(
ctx
)
.
Warn
(
"gateway.failover_switch_account"
,
zap
.
Int64
(
"account_id"
,
accountID
),
zap
.
Int
(
"upstream_status"
,
failoverErr
.
StatusCode
),
zap
.
Int
(
"switch_count"
,
s
.
SwitchCount
),
zap
.
Int
(
"max_switches"
,
s
.
MaxSwitches
),
)
// Antigravity 平台换号线性递增延时
if
platform
==
service
.
PlatformAntigravity
{
...
...
@@ -127,13 +136,18 @@ func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAc
s
.
LastFailoverErr
.
StatusCode
==
http
.
StatusServiceUnavailable
&&
s
.
SwitchCount
<=
s
.
MaxSwitches
{
log
.
Printf
(
"Antigravity single-account 503 backoff: waiting %v before retry (attempt %d)"
,
singleAccountBackoffDelay
,
s
.
SwitchCount
)
logger
.
FromContext
(
ctx
)
.
Warn
(
"gateway.failover_single_account_backoff"
,
zap
.
Duration
(
"backoff_delay"
,
singleAccountBackoffDelay
),
zap
.
Int
(
"switch_count"
,
s
.
SwitchCount
),
zap
.
Int
(
"max_switches"
,
s
.
MaxSwitches
),
)
if
!
sleepWithContext
(
ctx
,
singleAccountBackoffDelay
)
{
return
FailoverCanceled
}
log
.
Printf
(
"Antigravity single-account 503 retry: clearing failed accounts, retry %d/%d"
,
s
.
SwitchCount
,
s
.
MaxSwitches
)
logger
.
FromContext
(
ctx
)
.
Warn
(
"gateway.failover_single_account_retry"
,
zap
.
Int
(
"switch_count"
,
s
.
SwitchCount
),
zap
.
Int
(
"max_switches"
,
s
.
MaxSwitches
),
)
s
.
FailedAccountIDs
=
make
(
map
[
int64
]
struct
{})
return
FailoverContinue
}
...
...
backend/internal/handler/gateway_handler.go
View file @
bb664d9b
...
...
@@ -6,9 +6,10 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
...
...
@@ -17,6 +18,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
pkgerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
pkghttputil
"github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
...
...
@@ -27,6 +29,10 @@ import (
"go.uber.org/zap"
)
const
gatewayCompatibilityMetricsLogInterval
=
1024
var
gatewayCompatibilityMetricsLogCounter
atomic
.
Uint64
// GatewayHandler handles API gateway requests
type
GatewayHandler
struct
{
gatewayService
*
service
.
GatewayService
...
...
@@ -109,9 +115,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
zap
.
Int64
(
"api_key_id"
,
apiKey
.
ID
),
zap
.
Any
(
"group_id"
,
apiKey
.
GroupID
),
)
defer
h
.
maybeLogCompatibilityFallbackMetrics
(
reqLog
)
// 读取请求体
body
,
err
:=
io
.
ReadAll
(
c
.
Request
.
Body
)
body
,
err
:=
pkghttputil
.
ReadRequestBodyWithPrealloc
(
c
.
Request
)
if
err
!=
nil
{
if
maxErr
,
ok
:=
extractMaxBytesError
(
err
);
ok
{
h
.
errorResponse
(
c
,
http
.
StatusRequestEntityTooLarge
,
"invalid_request_error"
,
buildBodyTooLargeMessage
(
maxErr
.
Limit
))
...
...
@@ -140,16 +147,16 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 设置 max_tokens=1 + haiku 探测请求标识到 context 中
// 必须在 SetClaudeCodeClientContext 之前设置,因为 ClaudeCodeValidator 需要读取此标识进行绕过判断
if
isMaxTokensOneHaikuRequest
(
reqModel
,
parsedReq
.
MaxTokens
,
reqStream
)
{
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
IsMaxTokensOneHaikuRequest
,
true
)
ctx
:=
service
.
WithIsMaxTokensOneHaikuRequest
(
c
.
Request
.
Context
(),
true
,
h
.
metadataBridgeEnabled
()
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
// 检查是否为 Claude Code 客户端,设置到 context 中
SetClaudeCodeClientContext
(
c
,
body
)
// 检查是否为 Claude Code 客户端,设置到 context 中
(复用已解析请求,避免二次反序列化)。
SetClaudeCodeClientContext
(
c
,
body
,
parsedReq
)
isClaudeCodeClient
:=
service
.
IsClaudeCodeClient
(
c
.
Request
.
Context
())
// 在请求上下文中记录 thinking 状态,供 Antigravity 最终模型 key 推导/模型维度限流使用
c
.
Request
=
c
.
Request
.
WithContext
(
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
ThinkingEnabled
,
parsedReq
.
Thinking
Enabled
))
c
.
Request
=
c
.
Request
.
WithContext
(
service
.
WithThinkingEnabled
(
c
.
Request
.
Context
(),
parsedReq
.
ThinkingEnabled
,
h
.
metadataBridge
Enabled
()
))
setOpsRequestContext
(
c
,
reqModel
,
reqStream
,
body
)
...
...
@@ -247,8 +254,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if
apiKey
.
GroupID
!=
nil
{
prefetchedGroupID
=
*
apiKey
.
GroupID
}
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
PrefetchedStickyAccountID
,
sessionBoundAccountID
)
ctx
=
context
.
WithValue
(
ctx
,
ctxkey
.
PrefetchedStickyGroupID
,
prefetchedGroupID
)
ctx
:=
service
.
WithPrefetchedStickySession
(
c
.
Request
.
Context
(),
sessionBoundAccountID
,
prefetchedGroupID
,
h
.
metadataBridgeEnabled
())
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
}
...
...
@@ -261,7 +267,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
if
h
.
gatewayService
.
IsSingleAntigravityAccountGroup
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
)
{
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
ctx
:=
service
.
WithSingleAccountRetry
(
c
.
Request
.
Context
(),
true
,
h
.
metadataBridgeEnabled
()
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
...
...
@@ -275,7 +281,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
action
:=
fs
.
HandleSelectionExhausted
(
c
.
Request
.
Context
())
switch
action
{
case
FailoverContinue
:
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
ctx
:=
service
.
WithSingleAccountRetry
(
c
.
Request
.
Context
(),
true
,
h
.
metadataBridgeEnabled
()
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
continue
case
FailoverCanceled
:
...
...
@@ -364,7 +370,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
var
result
*
service
.
ForwardResult
requestCtx
:=
c
.
Request
.
Context
()
if
fs
.
SwitchCount
>
0
{
requestCtx
=
context
.
WithValue
(
requestCtx
,
ctxkey
.
AccountSwitchCount
,
fs
.
SwitchCount
)
requestCtx
=
service
.
With
AccountSwitchCount
(
requestCtx
,
fs
.
SwitchCount
,
h
.
metadataBridgeEnabled
()
)
}
if
account
.
Platform
==
service
.
PlatformAntigravity
{
result
,
err
=
h
.
antigravityGatewayService
.
ForwardGemini
(
requestCtx
,
c
,
account
,
reqModel
,
"generateContent"
,
reqStream
,
body
,
hasBoundSession
)
...
...
@@ -439,7 +445,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
if
h
.
gatewayService
.
IsSingleAntigravityAccountGroup
(
c
.
Request
.
Context
(),
currentAPIKey
.
GroupID
)
{
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
ctx
:=
service
.
WithSingleAccountRetry
(
c
.
Request
.
Context
(),
true
,
h
.
metadataBridgeEnabled
()
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
...
...
@@ -458,7 +464,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
action
:=
fs
.
HandleSelectionExhausted
(
c
.
Request
.
Context
())
switch
action
{
case
FailoverContinue
:
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
ctx
:=
service
.
WithSingleAccountRetry
(
c
.
Request
.
Context
(),
true
,
h
.
metadataBridgeEnabled
()
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
continue
case
FailoverCanceled
:
...
...
@@ -547,7 +553,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
var
result
*
service
.
ForwardResult
requestCtx
:=
c
.
Request
.
Context
()
if
fs
.
SwitchCount
>
0
{
requestCtx
=
context
.
WithValue
(
requestCtx
,
ctxkey
.
AccountSwitchCount
,
fs
.
SwitchCount
)
requestCtx
=
service
.
With
AccountSwitchCount
(
requestCtx
,
fs
.
SwitchCount
,
h
.
metadataBridgeEnabled
()
)
}
if
account
.
Platform
==
service
.
PlatformAntigravity
&&
account
.
Type
!=
service
.
AccountTypeAPIKey
{
result
,
err
=
h
.
antigravityGatewayService
.
Forward
(
requestCtx
,
c
,
account
,
body
,
hasBoundSession
)
...
...
@@ -956,20 +962,8 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
// Stream already started, send error as SSE event then close
flusher
,
ok
:=
c
.
Writer
.
(
http
.
Flusher
)
if
ok
{
// Send error event in SSE format with proper JSON marshaling
errorData
:=
map
[
string
]
any
{
"type"
:
"error"
,
"error"
:
map
[
string
]
string
{
"type"
:
errType
,
"message"
:
message
,
},
}
jsonBytes
,
err
:=
json
.
Marshal
(
errorData
)
if
err
!=
nil
{
_
=
c
.
Error
(
err
)
return
}
errorEvent
:=
fmt
.
Sprintf
(
"data: %s
\n\n
"
,
string
(
jsonBytes
))
// SSE 错误事件固定 schema,使用 Quote 直拼可避免额外 Marshal 分配。
errorEvent
:=
`data: {"type":"error","error":{"type":`
+
strconv
.
Quote
(
errType
)
+
`,"message":`
+
strconv
.
Quote
(
message
)
+
`}}`
+
"
\n\n
"
if
_
,
err
:=
fmt
.
Fprint
(
c
.
Writer
,
errorEvent
);
err
!=
nil
{
_
=
c
.
Error
(
err
)
}
...
...
@@ -1024,9 +1018,10 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
zap
.
Int64
(
"api_key_id"
,
apiKey
.
ID
),
zap
.
Any
(
"group_id"
,
apiKey
.
GroupID
),
)
defer
h
.
maybeLogCompatibilityFallbackMetrics
(
reqLog
)
// 读取请求体
body
,
err
:=
io
.
ReadAll
(
c
.
Request
.
Body
)
body
,
err
:=
pkghttputil
.
ReadRequestBodyWithPrealloc
(
c
.
Request
)
if
err
!=
nil
{
if
maxErr
,
ok
:=
extractMaxBytesError
(
err
);
ok
{
h
.
errorResponse
(
c
,
http
.
StatusRequestEntityTooLarge
,
"invalid_request_error"
,
buildBodyTooLargeMessage
(
maxErr
.
Limit
))
...
...
@@ -1041,9 +1036,6 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
return
}
// 检查是否为 Claude Code 客户端,设置到 context 中
SetClaudeCodeClientContext
(
c
,
body
)
setOpsRequestContext
(
c
,
""
,
false
,
body
)
parsedReq
,
err
:=
service
.
ParseGatewayRequest
(
body
,
domain
.
PlatformAnthropic
)
...
...
@@ -1051,9 +1043,11 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
h
.
errorResponse
(
c
,
http
.
StatusBadRequest
,
"invalid_request_error"
,
"Failed to parse request body"
)
return
}
// count_tokens 走 messages 严格校验时,复用已解析请求,避免二次反序列化。
SetClaudeCodeClientContext
(
c
,
body
,
parsedReq
)
reqLog
=
reqLog
.
With
(
zap
.
String
(
"model"
,
parsedReq
.
Model
),
zap
.
Bool
(
"stream"
,
parsedReq
.
Stream
))
// 在请求上下文中记录 thinking 状态,供 Antigravity 最终模型 key 推导/模型维度限流使用
c
.
Request
=
c
.
Request
.
WithContext
(
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
ThinkingEnabled
,
parsedReq
.
Thinking
Enabled
))
c
.
Request
=
c
.
Request
.
WithContext
(
service
.
WithThinkingEnabled
(
c
.
Request
.
Context
(),
parsedReq
.
ThinkingEnabled
,
h
.
metadataBridge
Enabled
()
))
// 验证 model 必填
if
parsedReq
.
Model
==
""
{
...
...
@@ -1217,24 +1211,8 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
textDeltas
=
[]
string
{
"New"
,
" Conversation"
}
}
// Build message_start event with proper JSON marshaling
messageStart
:=
map
[
string
]
any
{
"type"
:
"message_start"
,
"message"
:
map
[
string
]
any
{
"id"
:
msgID
,
"type"
:
"message"
,
"role"
:
"assistant"
,
"model"
:
model
,
"content"
:
[]
any
{},
"stop_reason"
:
nil
,
"stop_sequence"
:
nil
,
"usage"
:
map
[
string
]
int
{
"input_tokens"
:
10
,
"output_tokens"
:
0
,
},
},
}
messageStartJSON
,
_
:=
json
.
Marshal
(
messageStart
)
// Build message_start event with fixed schema.
messageStartJSON
:=
`{"type":"message_start","message":{"id":`
+
strconv
.
Quote
(
msgID
)
+
`,"type":"message","role":"assistant","model":`
+
strconv
.
Quote
(
model
)
+
`,"content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}`
// Build events
events
:=
[]
string
{
...
...
@@ -1244,31 +1222,12 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
// Add text deltas
for
_
,
text
:=
range
textDeltas
{
delta
:=
map
[
string
]
any
{
"type"
:
"content_block_delta"
,
"index"
:
0
,
"delta"
:
map
[
string
]
string
{
"type"
:
"text_delta"
,
"text"
:
text
,
},
}
deltaJSON
,
_
:=
json
.
Marshal
(
delta
)
deltaJSON
:=
`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":`
+
strconv
.
Quote
(
text
)
+
`}}`
events
=
append
(
events
,
`event: content_block_delta`
+
"
\n
"
+
`data: `
+
string
(
deltaJSON
))
}
// Add final events
messageDelta
:=
map
[
string
]
any
{
"type"
:
"message_delta"
,
"delta"
:
map
[
string
]
any
{
"stop_reason"
:
"end_turn"
,
"stop_sequence"
:
nil
,
},
"usage"
:
map
[
string
]
int
{
"input_tokens"
:
10
,
"output_tokens"
:
outputTokens
,
},
}
messageDeltaJSON
,
_
:=
json
.
Marshal
(
messageDelta
)
messageDeltaJSON
:=
`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":`
+
strconv
.
Itoa
(
outputTokens
)
+
`}}`
events
=
append
(
events
,
`event: content_block_stop`
+
"
\n
"
+
`data: {"index":0,"type":"content_block_stop"}`
,
...
...
@@ -1366,6 +1325,30 @@ func billingErrorDetails(err error) (status int, code, message string) {
return
http
.
StatusForbidden
,
"billing_error"
,
msg
}
func
(
h
*
GatewayHandler
)
metadataBridgeEnabled
()
bool
{
if
h
==
nil
||
h
.
cfg
==
nil
{
return
true
}
return
h
.
cfg
.
Gateway
.
OpenAIWS
.
MetadataBridgeEnabled
}
func
(
h
*
GatewayHandler
)
maybeLogCompatibilityFallbackMetrics
(
reqLog
*
zap
.
Logger
)
{
if
reqLog
==
nil
{
return
}
if
gatewayCompatibilityMetricsLogCounter
.
Add
(
1
)
%
gatewayCompatibilityMetricsLogInterval
!=
0
{
return
}
metrics
:=
service
.
SnapshotOpenAICompatibilityFallbackMetrics
()
reqLog
.
Info
(
"gateway.compatibility_fallback_metrics"
,
zap
.
Int64
(
"session_hash_legacy_read_fallback_total"
,
metrics
.
SessionHashLegacyReadFallbackTotal
),
zap
.
Int64
(
"session_hash_legacy_read_fallback_hit"
,
metrics
.
SessionHashLegacyReadFallbackHit
),
zap
.
Int64
(
"session_hash_legacy_dual_write_total"
,
metrics
.
SessionHashLegacyDualWriteTotal
),
zap
.
Float64
(
"session_hash_legacy_read_hit_rate"
,
metrics
.
SessionHashLegacyReadHitRate
),
zap
.
Int64
(
"metadata_legacy_fallback_total"
,
metrics
.
MetadataLegacyFallbackTotal
),
)
}
func
(
h
*
GatewayHandler
)
submitUsageRecordTask
(
task
service
.
UsageRecordTask
)
{
if
task
==
nil
{
return
...
...
@@ -1377,5 +1360,13 @@ func (h *GatewayHandler) submitUsageRecordTask(task service.UsageRecordTask) {
// 回退路径:worker 池未注入时同步执行,避免退回到无界 goroutine 模式。
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
10
*
time
.
Second
)
defer
cancel
()
defer
func
()
{
if
recovered
:=
recover
();
recovered
!=
nil
{
logger
.
L
()
.
With
(
zap
.
String
(
"component"
,
"handler.gateway.messages"
),
zap
.
Any
(
"panic"
,
recovered
),
)
.
Error
(
"gateway.usage_record_task_panic_recovered"
)
}
}()
task
(
ctx
)
}
backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go
View file @
bb664d9b
...
...
@@ -119,6 +119,13 @@ func (f *fakeConcurrencyCache) GetAccountsLoadBatch(context.Context, []service.A
func
(
f
*
fakeConcurrencyCache
)
GetUsersLoadBatch
(
context
.
Context
,
[]
service
.
UserWithConcurrency
)
(
map
[
int64
]
*
service
.
UserLoadInfo
,
error
)
{
return
map
[
int64
]
*
service
.
UserLoadInfo
{},
nil
}
func
(
f
*
fakeConcurrencyCache
)
GetAccountConcurrencyBatch
(
_
context
.
Context
,
accountIDs
[]
int64
)
(
map
[
int64
]
int
,
error
)
{
result
:=
make
(
map
[
int64
]
int
,
len
(
accountIDs
))
for
_
,
id
:=
range
accountIDs
{
result
[
id
]
=
0
}
return
result
,
nil
}
func
(
f
*
fakeConcurrencyCache
)
CleanupExpiredAccountSlots
(
context
.
Context
,
int64
)
error
{
return
nil
}
func
newTestGatewayHandler
(
t
*
testing
.
T
,
group
*
service
.
Group
,
accounts
[]
*
service
.
Account
)
(
*
GatewayHandler
,
func
())
{
...
...
backend/internal/handler/gateway_helper.go
View file @
bb664d9b
...
...
@@ -18,12 +18,17 @@ import (
// claudeCodeValidator is a singleton validator for Claude Code client detection
var
claudeCodeValidator
=
service
.
NewClaudeCodeValidator
()
const
claudeCodeParsedRequestContextKey
=
"claude_code_parsed_request"
// SetClaudeCodeClientContext 检查请求是否来自 Claude Code 客户端,并设置到 context 中
// 返回更新后的 context
func
SetClaudeCodeClientContext
(
c
*
gin
.
Context
,
body
[]
byte
)
{
func
SetClaudeCodeClientContext
(
c
*
gin
.
Context
,
body
[]
byte
,
parsedReq
*
service
.
ParsedRequest
)
{
if
c
==
nil
||
c
.
Request
==
nil
{
return
}
if
parsedReq
!=
nil
{
c
.
Set
(
claudeCodeParsedRequestContextKey
,
parsedReq
)
}
// Fast path:非 Claude CLI UA 直接判定 false,避免热路径二次 JSON 反序列化。
if
!
claudeCodeValidator
.
ValidateUserAgent
(
c
.
GetHeader
(
"User-Agent"
))
{
ctx
:=
service
.
SetClaudeCodeClient
(
c
.
Request
.
Context
(),
false
)
...
...
@@ -37,8 +42,11 @@ func SetClaudeCodeClientContext(c *gin.Context, body []byte) {
isClaudeCode
=
true
}
else
{
// 仅在确认为 Claude CLI 且 messages 路径时再做 body 解析。
var
bodyMap
map
[
string
]
any
if
len
(
body
)
>
0
{
bodyMap
:=
claudeCodeBodyMapFromParsedRequest
(
parsedReq
)
if
bodyMap
==
nil
{
bodyMap
=
claudeCodeBodyMapFromContextCache
(
c
)
}
if
bodyMap
==
nil
&&
len
(
body
)
>
0
{
_
=
json
.
Unmarshal
(
body
,
&
bodyMap
)
}
isClaudeCode
=
claudeCodeValidator
.
Validate
(
c
.
Request
,
bodyMap
)
...
...
@@ -49,6 +57,42 @@ func SetClaudeCodeClientContext(c *gin.Context, body []byte) {
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
func
claudeCodeBodyMapFromParsedRequest
(
parsedReq
*
service
.
ParsedRequest
)
map
[
string
]
any
{
if
parsedReq
==
nil
{
return
nil
}
bodyMap
:=
map
[
string
]
any
{
"model"
:
parsedReq
.
Model
,
}
if
parsedReq
.
System
!=
nil
||
parsedReq
.
HasSystem
{
bodyMap
[
"system"
]
=
parsedReq
.
System
}
if
parsedReq
.
MetadataUserID
!=
""
{
bodyMap
[
"metadata"
]
=
map
[
string
]
any
{
"user_id"
:
parsedReq
.
MetadataUserID
}
}
return
bodyMap
}
func
claudeCodeBodyMapFromContextCache
(
c
*
gin
.
Context
)
map
[
string
]
any
{
if
c
==
nil
{
return
nil
}
if
cached
,
ok
:=
c
.
Get
(
service
.
OpenAIParsedRequestBodyKey
);
ok
{
if
bodyMap
,
ok
:=
cached
.
(
map
[
string
]
any
);
ok
{
return
bodyMap
}
}
if
cached
,
ok
:=
c
.
Get
(
claudeCodeParsedRequestContextKey
);
ok
{
switch
v
:=
cached
.
(
type
)
{
case
*
service
.
ParsedRequest
:
return
claudeCodeBodyMapFromParsedRequest
(
v
)
case
service
.
ParsedRequest
:
return
claudeCodeBodyMapFromParsedRequest
(
&
v
)
}
}
return
nil
}
// 并发槽位等待相关常量
//
// 性能优化说明:
...
...
backend/internal/handler/gateway_helper_fastpath_test.go
View file @
bb664d9b
...
...
@@ -33,6 +33,14 @@ func (m *concurrencyCacheMock) GetAccountConcurrency(ctx context.Context, accoun
return
0
,
nil
}
func
(
m
*
concurrencyCacheMock
)
GetAccountConcurrencyBatch
(
ctx
context
.
Context
,
accountIDs
[]
int64
)
(
map
[
int64
]
int
,
error
)
{
result
:=
make
(
map
[
int64
]
int
,
len
(
accountIDs
))
for
_
,
accountID
:=
range
accountIDs
{
result
[
accountID
]
=
0
}
return
result
,
nil
}
func
(
m
*
concurrencyCacheMock
)
IncrementAccountWaitCount
(
ctx
context
.
Context
,
accountID
int64
,
maxWait
int
)
(
bool
,
error
)
{
return
true
,
nil
}
...
...
backend/internal/handler/gateway_helper_hotpath_test.go
View file @
bb664d9b
...
...
@@ -49,6 +49,14 @@ func (s *helperConcurrencyCacheStub) GetAccountConcurrency(ctx context.Context,
return
0
,
nil
}
func
(
s
*
helperConcurrencyCacheStub
)
GetAccountConcurrencyBatch
(
ctx
context
.
Context
,
accountIDs
[]
int64
)
(
map
[
int64
]
int
,
error
)
{
out
:=
make
(
map
[
int64
]
int
,
len
(
accountIDs
))
for
_
,
accountID
:=
range
accountIDs
{
out
[
accountID
]
=
0
}
return
out
,
nil
}
func
(
s
*
helperConcurrencyCacheStub
)
IncrementAccountWaitCount
(
ctx
context
.
Context
,
accountID
int64
,
maxWait
int
)
(
bool
,
error
)
{
return
true
,
nil
}
...
...
@@ -133,7 +141,7 @@ func TestSetClaudeCodeClientContext_FastPathAndStrictPath(t *testing.T) {
c
,
_
:=
newHelperTestContext
(
http
.
MethodPost
,
"/v1/messages"
)
c
.
Request
.
Header
.
Set
(
"User-Agent"
,
"curl/8.6.0"
)
SetClaudeCodeClientContext
(
c
,
validClaudeCodeBodyJSON
())
SetClaudeCodeClientContext
(
c
,
validClaudeCodeBodyJSON
()
,
nil
)
require
.
False
(
t
,
service
.
IsClaudeCodeClient
(
c
.
Request
.
Context
()))
})
...
...
@@ -141,7 +149,7 @@ func TestSetClaudeCodeClientContext_FastPathAndStrictPath(t *testing.T) {
c
,
_
:=
newHelperTestContext
(
http
.
MethodGet
,
"/v1/models"
)
c
.
Request
.
Header
.
Set
(
"User-Agent"
,
"claude-cli/1.0.1"
)
SetClaudeCodeClientContext
(
c
,
nil
)
SetClaudeCodeClientContext
(
c
,
nil
,
nil
)
require
.
True
(
t
,
service
.
IsClaudeCodeClient
(
c
.
Request
.
Context
()))
})
...
...
@@ -152,7 +160,7 @@ func TestSetClaudeCodeClientContext_FastPathAndStrictPath(t *testing.T) {
c
.
Request
.
Header
.
Set
(
"anthropic-beta"
,
"message-batches-2024-09-24"
)
c
.
Request
.
Header
.
Set
(
"anthropic-version"
,
"2023-06-01"
)
SetClaudeCodeClientContext
(
c
,
validClaudeCodeBodyJSON
())
SetClaudeCodeClientContext
(
c
,
validClaudeCodeBodyJSON
()
,
nil
)
require
.
True
(
t
,
service
.
IsClaudeCodeClient
(
c
.
Request
.
Context
()))
})
...
...
@@ -160,11 +168,51 @@ func TestSetClaudeCodeClientContext_FastPathAndStrictPath(t *testing.T) {
c
,
_
:=
newHelperTestContext
(
http
.
MethodPost
,
"/v1/messages"
)
c
.
Request
.
Header
.
Set
(
"User-Agent"
,
"claude-cli/1.0.1"
)
// 缺少严格校验所需 header + body 字段
SetClaudeCodeClientContext
(
c
,
[]
byte
(
`{"model":"x"}`
))
SetClaudeCodeClientContext
(
c
,
[]
byte
(
`{"model":"x"}`
)
,
nil
)
require
.
False
(
t
,
service
.
IsClaudeCodeClient
(
c
.
Request
.
Context
()))
})
}
func
TestSetClaudeCodeClientContext_ReuseParsedRequestAndContextCache
(
t
*
testing
.
T
)
{
t
.
Run
(
"reuse parsed request without body unmarshal"
,
func
(
t
*
testing
.
T
)
{
c
,
_
:=
newHelperTestContext
(
http
.
MethodPost
,
"/v1/messages"
)
c
.
Request
.
Header
.
Set
(
"User-Agent"
,
"claude-cli/1.0.1"
)
c
.
Request
.
Header
.
Set
(
"X-App"
,
"claude-code"
)
c
.
Request
.
Header
.
Set
(
"anthropic-beta"
,
"message-batches-2024-09-24"
)
c
.
Request
.
Header
.
Set
(
"anthropic-version"
,
"2023-06-01"
)
parsedReq
:=
&
service
.
ParsedRequest
{
Model
:
"claude-3-5-sonnet-20241022"
,
System
:
[]
any
{
map
[
string
]
any
{
"text"
:
"You are Claude Code, Anthropic's official CLI for Claude."
},
},
MetadataUserID
:
"user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_abc-123"
,
}
// body 非法 JSON,如果函数复用 parsedReq 成功则仍应判定为 Claude Code。
SetClaudeCodeClientContext
(
c
,
[]
byte
(
`{invalid`
),
parsedReq
)
require
.
True
(
t
,
service
.
IsClaudeCodeClient
(
c
.
Request
.
Context
()))
})
t
.
Run
(
"reuse context cache without body unmarshal"
,
func
(
t
*
testing
.
T
)
{
c
,
_
:=
newHelperTestContext
(
http
.
MethodPost
,
"/v1/messages"
)
c
.
Request
.
Header
.
Set
(
"User-Agent"
,
"claude-cli/1.0.1"
)
c
.
Request
.
Header
.
Set
(
"X-App"
,
"claude-code"
)
c
.
Request
.
Header
.
Set
(
"anthropic-beta"
,
"message-batches-2024-09-24"
)
c
.
Request
.
Header
.
Set
(
"anthropic-version"
,
"2023-06-01"
)
c
.
Set
(
service
.
OpenAIParsedRequestBodyKey
,
map
[
string
]
any
{
"model"
:
"claude-3-5-sonnet-20241022"
,
"system"
:
[]
any
{
map
[
string
]
any
{
"text"
:
"You are Claude Code, Anthropic's official CLI for Claude."
},
},
"metadata"
:
map
[
string
]
any
{
"user_id"
:
"user_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_account__session_abc-123"
},
})
SetClaudeCodeClientContext
(
c
,
[]
byte
(
`{invalid`
),
nil
)
require
.
True
(
t
,
service
.
IsClaudeCodeClient
(
c
.
Request
.
Context
()))
})
}
func
TestWaitForSlotWithPingTimeout_AccountAndUserAcquire
(
t
*
testing
.
T
)
{
cache
:=
&
helperConcurrencyCacheStub
{
accountSeq
:
[]
bool
{
false
,
true
},
...
...
backend/internal/handler/gemini_v1beta_handler.go
View file @
bb664d9b
...
...
@@ -7,16 +7,15 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strings"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
pkghttputil
"github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
...
...
@@ -168,7 +167,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
stream
:=
action
==
"streamGenerateContent"
reqLog
=
reqLog
.
With
(
zap
.
String
(
"model"
,
modelName
),
zap
.
String
(
"action"
,
action
),
zap
.
Bool
(
"stream"
,
stream
))
body
,
err
:=
io
.
ReadAll
(
c
.
Request
.
Body
)
body
,
err
:=
pkghttputil
.
ReadRequestBodyWithPrealloc
(
c
.
Request
)
if
err
!=
nil
{
if
maxErr
,
ok
:=
extractMaxBytesError
(
err
);
ok
{
googleError
(
c
,
http
.
StatusRequestEntityTooLarge
,
buildBodyTooLargeMessage
(
maxErr
.
Limit
))
...
...
@@ -268,8 +267,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
if
apiKey
.
GroupID
!=
nil
{
prefetchedGroupID
=
*
apiKey
.
GroupID
}
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
PrefetchedStickyAccountID
,
sessionBoundAccountID
)
ctx
=
context
.
WithValue
(
ctx
,
ctxkey
.
PrefetchedStickyGroupID
,
prefetchedGroupID
)
ctx
:=
service
.
WithPrefetchedStickySession
(
c
.
Request
.
Context
(),
sessionBoundAccountID
,
prefetchedGroupID
,
h
.
metadataBridgeEnabled
())
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
}
...
...
@@ -349,7 +347,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
if
h
.
gatewayService
.
IsSingleAntigravityAccountGroup
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
)
{
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
ctx
:=
service
.
WithSingleAccountRetry
(
c
.
Request
.
Context
(),
true
,
h
.
metadataBridgeEnabled
()
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
...
...
@@ -363,7 +361,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
action
:=
fs
.
HandleSelectionExhausted
(
c
.
Request
.
Context
())
switch
action
{
case
FailoverContinue
:
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
SingleAccountRetry
,
true
)
ctx
:=
service
.
WithSingleAccountRetry
(
c
.
Request
.
Context
(),
true
,
h
.
metadataBridgeEnabled
()
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
continue
case
FailoverCanceled
:
...
...
@@ -456,7 +454,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
var
result
*
service
.
ForwardResult
requestCtx
:=
c
.
Request
.
Context
()
if
fs
.
SwitchCount
>
0
{
requestCtx
=
context
.
WithValue
(
requestCtx
,
ctxkey
.
AccountSwitchCount
,
fs
.
SwitchCount
)
requestCtx
=
service
.
With
AccountSwitchCount
(
requestCtx
,
fs
.
SwitchCount
,
h
.
metadataBridgeEnabled
()
)
}
if
account
.
Platform
==
service
.
PlatformAntigravity
&&
account
.
Type
!=
service
.
AccountTypeAPIKey
{
result
,
err
=
h
.
antigravityGatewayService
.
ForwardGemini
(
requestCtx
,
c
,
account
,
modelName
,
action
,
stream
,
body
,
hasBoundSession
)
...
...
backend/internal/handler/handler.go
View file @
bb664d9b
...
...
@@ -11,6 +11,7 @@ type AdminHandlers struct {
Group
*
admin
.
GroupHandler
Account
*
admin
.
AccountHandler
Announcement
*
admin
.
AnnouncementHandler
DataManagement
*
admin
.
DataManagementHandler
OAuth
*
admin
.
OAuthHandler
OpenAIOAuth
*
admin
.
OpenAIOAuthHandler
GeminiOAuth
*
admin
.
GeminiOAuthHandler
...
...
@@ -40,6 +41,7 @@ type Handlers struct {
Gateway
*
GatewayHandler
OpenAIGateway
*
OpenAIGatewayHandler
SoraGateway
*
SoraGatewayHandler
SoraClient
*
SoraClientHandler
Setting
*
SettingHandler
Totp
*
TotpHandler
}
...
...
Prev
1
2
3
4
5
6
7
8
…
13
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