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
5e060b22
Commit
5e060b22
authored
Apr 23, 2026
by
erio
Browse files
Merge remote-tracking branch 'upstream/main' into feat/channel-insights
# Conflicts: # backend/cmd/server/wire_gen.go
parents
6f04c25e
0a80ec80
Changes
106
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/admin_service_stub_test.go
View file @
5e060b22
...
...
@@ -183,6 +183,17 @@ func (s *stubAdminService) GetUserUsageStats(ctx context.Context, userID int64,
return
map
[
string
]
any
{
"user_id"
:
userID
},
nil
}
func
(
s
*
stubAdminService
)
GetUserRPMStatus
(
ctx
context
.
Context
,
userID
int64
)
(
*
service
.
UserRPMStatus
,
error
)
{
user
,
err
:=
s
.
GetUser
(
ctx
,
userID
)
if
err
!=
nil
{
return
nil
,
err
}
return
&
service
.
UserRPMStatus
{
UserRPMUsed
:
0
,
UserRPMLimit
:
user
.
RPMLimit
,
},
nil
}
func
(
s
*
stubAdminService
)
BindUserAuthIdentity
(
ctx
context
.
Context
,
userID
int64
,
input
service
.
AdminBindAuthIdentityInput
)
(
*
service
.
AdminBoundAuthIdentity
,
error
)
{
s
.
boundAuthIdentityFor
=
userID
copied
:=
input
...
...
@@ -276,6 +287,14 @@ func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int
return
nil
}
func
(
s
*
stubAdminService
)
ClearGroupRPMOverrides
(
_
context
.
Context
,
_
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
BatchSetGroupRPMOverrides
(
_
context
.
Context
,
_
int64
,
_
[]
service
.
GroupRPMOverrideInput
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
ListAccounts
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
accountType
,
status
,
search
string
,
groupID
int64
,
privacyMode
string
,
sortBy
,
sortOrder
string
)
([]
service
.
Account
,
int64
,
error
)
{
s
.
lastListAccounts
.
platform
=
platform
s
.
lastListAccounts
.
accountType
=
accountType
...
...
backend/internal/handler/admin/group_handler.go
View file @
5e060b22
...
...
@@ -110,6 +110,8 @@ type CreateGroupRequest struct {
RequirePrivacySet
bool
`json:"require_privacy_set"`
DefaultMappedModel
string
`json:"default_mapped_model"`
MessagesDispatchModelConfig
service
.
OpenAIMessagesDispatchModelConfig
`json:"messages_dispatch_model_config"`
// 分组 RPM 上限(0 = 不限制)
RPMLimit
int
`json:"rpm_limit"`
// 从指定分组复制账号(创建后自动绑定)
CopyAccountsFromGroupIDs
[]
int64
`json:"copy_accounts_from_group_ids"`
}
...
...
@@ -145,6 +147,8 @@ type UpdateGroupRequest struct {
RequirePrivacySet
*
bool
`json:"require_privacy_set"`
DefaultMappedModel
*
string
`json:"default_mapped_model"`
MessagesDispatchModelConfig
*
service
.
OpenAIMessagesDispatchModelConfig
`json:"messages_dispatch_model_config"`
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
RPMLimit
*
int
`json:"rpm_limit"`
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
CopyAccountsFromGroupIDs
[]
int64
`json:"copy_accounts_from_group_ids"`
}
...
...
@@ -262,6 +266,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
RequirePrivacySet
:
req
.
RequirePrivacySet
,
DefaultMappedModel
:
req
.
DefaultMappedModel
,
MessagesDispatchModelConfig
:
req
.
MessagesDispatchModelConfig
,
RPMLimit
:
req
.
RPMLimit
,
CopyAccountsFromGroupIDs
:
req
.
CopyAccountsFromGroupIDs
,
})
if
err
!=
nil
{
...
...
@@ -313,6 +318,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
RequirePrivacySet
:
req
.
RequirePrivacySet
,
DefaultMappedModel
:
req
.
DefaultMappedModel
,
MessagesDispatchModelConfig
:
req
.
MessagesDispatchModelConfig
,
RPMLimit
:
req
.
RPMLimit
,
CopyAccountsFromGroupIDs
:
req
.
CopyAccountsFromGroupIDs
,
})
if
err
!=
nil
{
...
...
@@ -477,6 +483,51 @@ func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"Rate multipliers updated successfully"
})
}
// BatchSetGroupRPMOverridesRequest represents batch set rpm_override request
type
BatchSetGroupRPMOverridesRequest
struct
{
Entries
[]
service
.
GroupRPMOverrideInput
`json:"entries" binding:"required"`
}
// BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group
// PUT /api/v1/admin/groups/:id/rpm-overrides
func
(
h
*
GroupHandler
)
BatchSetGroupRPMOverrides
(
c
*
gin
.
Context
)
{
groupID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid group ID"
)
return
}
var
req
BatchSetGroupRPMOverridesRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
err
:=
h
.
adminService
.
BatchSetGroupRPMOverrides
(
c
.
Request
.
Context
(),
groupID
,
req
.
Entries
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"RPM overrides updated successfully"
})
}
// ClearGroupRPMOverrides handles clearing all rpm_override for a group
// DELETE /api/v1/admin/groups/:id/rpm-overrides
func
(
h
*
GroupHandler
)
ClearGroupRPMOverrides
(
c
*
gin
.
Context
)
{
groupID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid group ID"
)
return
}
if
err
:=
h
.
adminService
.
ClearGroupRPMOverrides
(
c
.
Request
.
Context
(),
groupID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"RPM overrides cleared successfully"
})
}
// UpdateSortOrderRequest represents the request to update group sort orders
type
UpdateSortOrderRequest
struct
{
Updates
[]
struct
{
...
...
backend/internal/handler/admin/setting_handler.go
View file @
5e060b22
...
...
@@ -185,6 +185,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
settings
.
CustomEndpoints
),
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
DefaultBalance
:
settings
.
DefaultBalance
,
DefaultUserRPMLimit
:
settings
.
DefaultUserRPMLimit
,
DefaultSubscriptions
:
defaultSubscriptions
,
EnableModelFallback
:
settings
.
EnableModelFallback
,
FallbackModelAnthropic
:
settings
.
FallbackModelAnthropic
,
...
...
@@ -337,6 +338,7 @@ type UpdateSettingsRequest struct {
// 默认配置
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
DefaultUserRPMLimit
int
`json:"default_user_rpm_limit"`
DefaultSubscriptions
[]
dto
.
DefaultSubscriptionSetting
`json:"default_subscriptions"`
AuthSourceDefaultEmailBalance
*
float64
`json:"auth_source_default_email_balance"`
AuthSourceDefaultEmailConcurrency
*
int
`json:"auth_source_default_email_concurrency"`
...
...
@@ -1117,6 +1119,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
CustomEndpoints
:
customEndpointsJSON
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
DefaultBalance
:
req
.
DefaultBalance
,
DefaultUserRPMLimit
:
req
.
DefaultUserRPMLimit
,
DefaultSubscriptions
:
defaultSubscriptions
,
EnableModelFallback
:
req
.
EnableModelFallback
,
FallbackModelAnthropic
:
req
.
FallbackModelAnthropic
,
...
...
@@ -1430,6 +1433,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
updatedSettings
.
CustomEndpoints
),
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
DefaultBalance
:
updatedSettings
.
DefaultBalance
,
DefaultUserRPMLimit
:
updatedSettings
.
DefaultUserRPMLimit
,
DefaultSubscriptions
:
updatedDefaultSubscriptions
,
EnableModelFallback
:
updatedSettings
.
EnableModelFallback
,
FallbackModelAnthropic
:
updatedSettings
.
FallbackModelAnthropic
,
...
...
backend/internal/handler/admin/user_handler.go
View file @
5e060b22
...
...
@@ -40,6 +40,7 @@ type CreateUserRequest struct {
Notes
string
`json:"notes"`
Balance
float64
`json:"balance"`
Concurrency
int
`json:"concurrency"`
RPMLimit
int
`json:"rpm_limit"`
AllowedGroups
[]
int64
`json:"allowed_groups"`
}
...
...
@@ -52,6 +53,7 @@ type UpdateUserRequest struct {
Notes
*
string
`json:"notes"`
Balance
*
float64
`json:"balance"`
Concurrency
*
int
`json:"concurrency"`
RPMLimit
*
int
`json:"rpm_limit"`
Status
string
`json:"status" binding:"omitempty,oneof=active disabled"`
AllowedGroups
*
[]
int64
`json:"allowed_groups"`
// GroupRates 用户专属分组倍率配置
...
...
@@ -243,6 +245,7 @@ func (h *UserHandler) Create(c *gin.Context) {
Notes
:
req
.
Notes
,
Balance
:
req
.
Balance
,
Concurrency
:
req
.
Concurrency
,
RPMLimit
:
req
.
RPMLimit
,
AllowedGroups
:
req
.
AllowedGroups
,
})
if
err
!=
nil
{
...
...
@@ -276,6 +279,7 @@ func (h *UserHandler) Update(c *gin.Context) {
Notes
:
req
.
Notes
,
Balance
:
req
.
Balance
,
Concurrency
:
req
.
Concurrency
,
RPMLimit
:
req
.
RPMLimit
,
Status
:
req
.
Status
,
AllowedGroups
:
req
.
AllowedGroups
,
GroupRates
:
req
.
GroupRates
,
...
...
@@ -455,3 +459,21 @@ func (h *UserHandler) ReplaceGroup(c *gin.Context) {
"migrated_keys"
:
result
.
MigratedKeys
,
})
}
// GetUserRPMStatus 返回指定用户当前分钟的 RPM 用量
// GET /api/v1/admin/users/:id/rpm-status
func
(
h
*
UserHandler
)
GetUserRPMStatus
(
c
*
gin
.
Context
)
{
userID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid user ID"
)
return
}
status
,
err
:=
h
.
adminService
.
GetUserRPMStatus
(
c
.
Request
.
Context
(),
userID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
status
)
}
backend/internal/handler/dto/mappers.go
View file @
5e060b22
...
...
@@ -29,6 +29,7 @@ func UserFromServiceShallow(u *service.User) *User {
BalanceNotifyThreshold
:
u
.
BalanceNotifyThreshold
,
BalanceNotifyExtraEmails
:
NotifyEmailEntriesFromService
(
u
.
BalanceNotifyExtraEmails
),
TotalRecharged
:
u
.
TotalRecharged
,
RPMLimit
:
u
.
RPMLimit
,
}
}
...
...
@@ -184,6 +185,7 @@ func groupFromServiceBase(g *service.Group) Group {
AllowMessagesDispatch
:
g
.
AllowMessagesDispatch
,
RequireOAuthOnly
:
g
.
RequireOAuthOnly
,
RequirePrivacySet
:
g
.
RequirePrivacySet
,
RPMLimit
:
g
.
RPMLimit
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
}
...
...
backend/internal/handler/dto/settings.go
View file @
5e060b22
...
...
@@ -108,6 +108,7 @@ type SystemSettings struct {
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
DefaultUserRPMLimit
int
`json:"default_user_rpm_limit"`
DefaultSubscriptions
[]
DefaultSubscriptionSetting
`json:"default_subscriptions"`
// Model fallback configuration
...
...
backend/internal/handler/dto/types.go
View file @
5e060b22
...
...
@@ -26,6 +26,9 @@ type User struct {
BalanceNotifyExtraEmails
[]
NotifyEmailEntry
`json:"balance_notify_extra_emails"`
TotalRecharged
float64
`json:"total_recharged"`
// RPMLimit 用户级每分钟请求数上限(0 = 不限制),仅在所用分组未设置 rpm_limit 时作为兜底生效。
RPMLimit
int
`json:"rpm_limit"`
APIKeys
[]
APIKey
`json:"api_keys,omitempty"`
Subscriptions
[]
UserSubscription
`json:"subscriptions,omitempty"`
}
...
...
@@ -108,6 +111,9 @@ type Group struct {
RequireOAuthOnly
bool
`json:"require_oauth_only"`
RequirePrivacySet
bool
`json:"require_privacy_set"`
// RPMLimit 分组级每分钟请求数上限(0 = 不限制),设置后覆盖用户级 rpm_limit。
RPMLimit
int
`json:"rpm_limit"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
...
...
backend/internal/handler/gateway_handler.go
View file @
5e060b22
...
...
@@ -243,7 +243,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 2. 【新增】Wait后二次检查余额/订阅
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
reqLog
.
Info
(
"gateway.billing_eligibility_check_failed"
,
zap
.
Error
(
err
))
status
,
code
,
message
:=
billingErrorDetails
(
err
)
status
,
code
,
message
,
retryAfter
:=
billingErrorDetails
(
err
)
if
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
h
.
handleStreamingAwareError
(
c
,
status
,
code
,
message
,
streamStarted
)
return
}
...
...
@@ -758,7 +761,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
fallbackAPIKey
:=
cloneAPIKeyWithGroup
(
apiKey
,
fallbackGroup
)
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
fallbackAPIKey
.
User
,
fallbackAPIKey
,
fallbackGroup
,
nil
);
err
!=
nil
{
status
,
code
,
message
:=
billingErrorDetails
(
err
)
status
,
code
,
message
,
retryAfter
:=
billingErrorDetails
(
err
)
if
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
h
.
handleStreamingAwareError
(
c
,
status
,
code
,
message
,
streamStarted
)
return
}
...
...
@@ -1464,7 +1470,10 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
// 校验 billing eligibility(订阅/余额)
// 【注意】不计算并发,但需要校验订阅/余额
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
status
,
code
,
message
:=
billingErrorDetails
(
err
)
status
,
code
,
message
,
retryAfter
:=
billingErrorDetails
(
err
)
if
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
h
.
errorResponse
(
c
,
status
,
code
,
message
)
return
}
...
...
@@ -1707,25 +1716,32 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
c
.
JSON
(
http
.
StatusOK
,
response
)
}
func
billingErrorDetails
(
err
error
)
(
status
int
,
code
,
message
string
)
{
func
billingErrorDetails
(
err
error
)
(
status
int
,
code
,
message
string
,
retryAfter
int
)
{
if
errors
.
Is
(
err
,
service
.
ErrBillingServiceUnavailable
)
{
msg
:=
pkgerrors
.
Message
(
err
)
if
msg
==
""
{
msg
=
"Billing service temporarily unavailable. Please retry later."
}
return
http
.
StatusServiceUnavailable
,
"billing_service_error"
,
msg
return
http
.
StatusServiceUnavailable
,
"billing_service_error"
,
msg
,
0
}
if
errors
.
Is
(
err
,
service
.
ErrAPIKeyRateLimit5hExceeded
)
{
msg
:=
pkgerrors
.
Message
(
err
)
return
http
.
StatusTooManyRequests
,
"rate_limit_exceeded"
,
msg
return
http
.
StatusTooManyRequests
,
"rate_limit_exceeded"
,
msg
,
0
}
if
errors
.
Is
(
err
,
service
.
ErrAPIKeyRateLimit1dExceeded
)
{
msg
:=
pkgerrors
.
Message
(
err
)
return
http
.
StatusTooManyRequests
,
"rate_limit_exceeded"
,
msg
return
http
.
StatusTooManyRequests
,
"rate_limit_exceeded"
,
msg
,
0
}
if
errors
.
Is
(
err
,
service
.
ErrAPIKeyRateLimit7dExceeded
)
{
msg
:=
pkgerrors
.
Message
(
err
)
return
http
.
StatusTooManyRequests
,
"rate_limit_exceeded"
,
msg
return
http
.
StatusTooManyRequests
,
"rate_limit_exceeded"
,
msg
,
0
}
// 用户/分组 RPM 超限统一映射为 HTTP 429;保留与其它 rate_limit 一致的错误码便于客户端分类。
// 返回 Retry-After 秒数(当前分钟剩余秒数),让 SDK 自动退避。
if
errors
.
Is
(
err
,
service
.
ErrGroupRPMExceeded
)
||
errors
.
Is
(
err
,
service
.
ErrUserRPMExceeded
)
{
msg
:=
pkgerrors
.
Message
(
err
)
retrySeconds
:=
60
-
int
(
time
.
Now
()
.
Unix
()
%
60
)
return
http
.
StatusTooManyRequests
,
"rate_limit_exceeded"
,
msg
,
retrySeconds
}
msg
:=
pkgerrors
.
Message
(
err
)
if
msg
==
""
{
...
...
@@ -1735,7 +1751,7 @@ func billingErrorDetails(err error) (status int, code, message string) {
)
.
Warn
(
"gateway.billing_error_missing_message"
)
msg
=
"Billing error"
}
return
http
.
StatusForbidden
,
"billing_error"
,
msg
return
http
.
StatusForbidden
,
"billing_error"
,
msg
,
0
}
func
(
h
*
GatewayHandler
)
metadataBridgeEnabled
()
bool
{
...
...
backend/internal/handler/gateway_handler_billing_error_test.go
0 → 100644
View file @
5e060b22
package
handler
import
(
"net/http"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func
TestBillingErrorDetails_MapsGroupRPMExceededToTooManyRequests
(
t
*
testing
.
T
)
{
status
,
code
,
msg
,
retryAfter
:=
billingErrorDetails
(
service
.
ErrGroupRPMExceeded
)
require
.
Equal
(
t
,
http
.
StatusTooManyRequests
,
status
)
require
.
Equal
(
t
,
"rate_limit_exceeded"
,
code
)
require
.
NotEmpty
(
t
,
msg
)
require
.
Greater
(
t
,
retryAfter
,
0
,
"RPM exceeded should return positive Retry-After"
)
require
.
LessOrEqual
(
t
,
retryAfter
,
60
)
}
func
TestBillingErrorDetails_MapsUserRPMExceededToTooManyRequests
(
t
*
testing
.
T
)
{
status
,
code
,
msg
,
retryAfter
:=
billingErrorDetails
(
service
.
ErrUserRPMExceeded
)
require
.
Equal
(
t
,
http
.
StatusTooManyRequests
,
status
)
require
.
Equal
(
t
,
"rate_limit_exceeded"
,
code
)
require
.
NotEmpty
(
t
,
msg
)
require
.
Greater
(
t
,
retryAfter
,
0
,
"RPM exceeded should return positive Retry-After"
)
require
.
LessOrEqual
(
t
,
retryAfter
,
60
)
}
func
TestBillingErrorDetails_APIKeyRateLimitStillMaps
(
t
*
testing
.
T
)
{
// 回归保护:加 RPM 分支后不应影响已有 APIKey rate limit 的映射。
for
_
,
err
:=
range
[]
error
{
service
.
ErrAPIKeyRateLimit5hExceeded
,
service
.
ErrAPIKeyRateLimit1dExceeded
,
service
.
ErrAPIKeyRateLimit7dExceeded
,
}
{
status
,
code
,
_
,
_
:=
billingErrorDetails
(
err
)
require
.
Equal
(
t
,
http
.
StatusTooManyRequests
,
status
,
"status for %v"
,
err
)
require
.
Equal
(
t
,
"rate_limit_exceeded"
,
code
)
}
}
func
TestBillingErrorDetails_BillingServiceUnavailableMapsTo503
(
t
*
testing
.
T
)
{
status
,
code
,
_
,
retryAfter
:=
billingErrorDetails
(
service
.
ErrBillingServiceUnavailable
)
require
.
Equal
(
t
,
http
.
StatusServiceUnavailable
,
status
)
require
.
Equal
(
t
,
"billing_service_error"
,
code
)
require
.
Equal
(
t
,
0
,
retryAfter
,
"non-RPM errors should not set Retry-After"
)
}
func
TestBillingErrorDetails_UnknownErrorFallsBackTo403
(
t
*
testing
.
T
)
{
status
,
code
,
msg
,
_
:=
billingErrorDetails
(
service
.
ErrInsufficientBalance
)
require
.
Equal
(
t
,
http
.
StatusForbidden
,
status
)
require
.
Equal
(
t
,
"billing_error"
,
code
)
require
.
NotEmpty
(
t
,
msg
)
}
backend/internal/handler/gateway_handler_chat_completions.go
View file @
5e060b22
...
...
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"strconv"
"time"
pkghttputil
"github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
...
...
@@ -136,7 +137,10 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
// 2. Re-check billing
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
reqLog
.
Info
(
"gateway.cc.billing_check_failed"
,
zap
.
Error
(
err
))
status
,
code
,
message
:=
billingErrorDetails
(
err
)
status
,
code
,
message
,
retryAfter
:=
billingErrorDetails
(
err
)
if
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
h
.
chatCompletionsErrorResponse
(
c
,
status
,
code
,
message
)
return
}
...
...
backend/internal/handler/gateway_handler_responses.go
View file @
5e060b22
...
...
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"strconv"
"time"
pkghttputil
"github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
...
...
@@ -141,7 +142,10 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
// 2. Re-check billing
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
reqLog
.
Info
(
"gateway.responses.billing_check_failed"
,
zap
.
Error
(
err
))
status
,
code
,
message
:=
billingErrorDetails
(
err
)
status
,
code
,
message
,
retryAfter
:=
billingErrorDetails
(
err
)
if
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
h
.
responsesErrorResponse
(
c
,
status
,
code
,
message
)
return
}
...
...
backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go
View file @
5e060b22
...
...
@@ -173,7 +173,7 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi
// RunModeSimple:跳过计费检查,避免引入 repo/cache 依赖。
cfg
:=
&
config
.
Config
{
RunMode
:
config
.
RunModeSimple
}
billingCacheSvc
:=
service
.
NewBillingCacheService
(
nil
,
nil
,
nil
,
nil
,
cfg
)
billingCacheSvc
:=
service
.
NewBillingCacheService
(
nil
,
nil
,
nil
,
nil
,
nil
,
nil
,
cfg
)
concurrencySvc
:=
service
.
NewConcurrencyService
(
&
fakeConcurrencyCache
{})
concurrencyHelper
:=
NewConcurrencyHelper
(
concurrencySvc
,
SSEPingFormatClaude
,
0
)
...
...
backend/internal/handler/gemini_v1beta_handler.go
View file @
5e060b22
...
...
@@ -9,6 +9,7 @@ import (
"errors"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/domain"
...
...
@@ -241,7 +242,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// 2) billing eligibility check (after wait)
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
reqLog
.
Info
(
"gemini.billing_eligibility_check_failed"
,
zap
.
Error
(
err
))
status
,
_
,
message
:=
billingErrorDetails
(
err
)
status
,
_
,
message
,
retryAfter
:=
billingErrorDetails
(
err
)
if
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
googleError
(
c
,
status
,
message
)
return
}
...
...
backend/internal/handler/openai_chat_completions.go
View file @
5e060b22
...
...
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"strconv"
"time"
pkghttputil
"github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
...
...
@@ -101,7 +102,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
reqLog
.
Info
(
"openai_chat_completions.billing_eligibility_check_failed"
,
zap
.
Error
(
err
))
status
,
code
,
message
:=
billingErrorDetails
(
err
)
status
,
code
,
message
,
retryAfter
:=
billingErrorDetails
(
err
)
if
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
h
.
handleStreamingAwareError
(
c
,
status
,
code
,
message
,
streamStarted
)
return
}
...
...
backend/internal/handler/openai_gateway_handler.go
View file @
5e060b22
...
...
@@ -228,7 +228,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
// 2. Re-check billing eligibility after wait
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
reqLog
.
Info
(
"openai.billing_eligibility_check_failed"
,
zap
.
Error
(
err
))
status
,
code
,
message
:=
billingErrorDetails
(
err
)
status
,
code
,
message
,
retryAfter
:=
billingErrorDetails
(
err
)
if
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
h
.
handleStreamingAwareError
(
c
,
status
,
code
,
message
,
streamStarted
)
return
}
...
...
@@ -594,7 +597,10 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
reqLog
.
Info
(
"openai_messages.billing_eligibility_check_failed"
,
zap
.
Error
(
err
))
status
,
code
,
message
:=
billingErrorDetails
(
err
)
status
,
code
,
message
,
retryAfter
:=
billingErrorDetails
(
err
)
if
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
h
.
anthropicStreamingAwareError
(
c
,
status
,
code
,
message
,
streamStarted
)
return
}
...
...
backend/internal/handler/openai_images.go
View file @
5e060b22
...
...
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
...
...
@@ -108,7 +109,10 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
if
err
:=
h
.
billingCacheService
.
CheckBillingEligibility
(
c
.
Request
.
Context
(),
apiKey
.
User
,
apiKey
,
apiKey
.
Group
,
subscription
);
err
!=
nil
{
reqLog
.
Info
(
"openai.images.billing_eligibility_check_failed"
,
zap
.
Error
(
err
))
status
,
code
,
message
:=
billingErrorDetails
(
err
)
status
,
code
,
message
,
retryAfter
:=
billingErrorDetails
(
err
)
if
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
h
.
handleStreamingAwareError
(
c
,
status
,
code
,
message
,
streamStarted
)
return
}
...
...
backend/internal/repository/api_key_repo.go
View file @
5e060b22
...
...
@@ -152,6 +152,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
user
.
FieldSignupSource
,
user
.
FieldLastLoginAt
,
user
.
FieldLastActiveAt
,
user
.
FieldRpmLimit
,
)
})
.
WithGroup
(
func
(
q
*
dbent
.
GroupQuery
)
{
...
...
@@ -178,6 +179,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group
.
FieldAllowMessagesDispatch
,
group
.
FieldDefaultMappedModel
,
group
.
FieldMessagesDispatchModelConfig
,
group
.
FieldRpmLimit
,
)
})
.
Only
(
ctx
)
...
...
@@ -669,6 +671,7 @@ func userEntityToService(u *dbent.User) *service.User {
BalanceNotifyThresholdType
:
u
.
BalanceNotifyThresholdType
,
BalanceNotifyThreshold
:
u
.
BalanceNotifyThreshold
,
TotalRecharged
:
u
.
TotalRecharged
,
RPMLimit
:
u
.
RpmLimit
,
CreatedAt
:
u
.
CreatedAt
,
UpdatedAt
:
u
.
UpdatedAt
,
}
...
...
@@ -713,6 +716,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
RequirePrivacySet
:
g
.
RequirePrivacySet
,
DefaultMappedModel
:
g
.
DefaultMappedModel
,
MessagesDispatchModelConfig
:
g
.
MessagesDispatchModelConfig
,
RPMLimit
:
g
.
RpmLimit
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
}
...
...
backend/internal/repository/group_repo.go
View file @
5e060b22
...
...
@@ -63,7 +63,8 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
SetRequireOauthOnly
(
groupIn
.
RequireOAuthOnly
)
.
SetRequirePrivacySet
(
groupIn
.
RequirePrivacySet
)
.
SetDefaultMappedModel
(
groupIn
.
DefaultMappedModel
)
.
SetMessagesDispatchModelConfig
(
groupIn
.
MessagesDispatchModelConfig
)
SetMessagesDispatchModelConfig
(
groupIn
.
MessagesDispatchModelConfig
)
.
SetRpmLimit
(
groupIn
.
RPMLimit
)
// 设置模型路由配置
if
groupIn
.
ModelRouting
!=
nil
{
...
...
@@ -130,7 +131,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
SetRequireOauthOnly
(
groupIn
.
RequireOAuthOnly
)
.
SetRequirePrivacySet
(
groupIn
.
RequirePrivacySet
)
.
SetDefaultMappedModel
(
groupIn
.
DefaultMappedModel
)
.
SetMessagesDispatchModelConfig
(
groupIn
.
MessagesDispatchModelConfig
)
SetMessagesDispatchModelConfig
(
groupIn
.
MessagesDispatchModelConfig
)
.
SetRpmLimit
(
groupIn
.
RPMLimit
)
// 显式处理可空字段:nil 需要 clear,非 nil 需要 set。
if
groupIn
.
DailyLimitUSD
!=
nil
{
...
...
backend/internal/repository/openai_403_counter_cache.go
0 → 100644
View file @
5e060b22
package
repository
import
(
"context"
"fmt"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const
openAI403CounterPrefix
=
"openai_403_count:account:"
var
openAI403CounterIncrScript
=
redis
.
NewScript
(
`
local key = KEYS[1]
local ttl = tonumber(ARGV[1])
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, ttl)
end
return count
`
)
type
openAI403CounterCache
struct
{
rdb
*
redis
.
Client
}
func
NewOpenAI403CounterCache
(
rdb
*
redis
.
Client
)
service
.
OpenAI403CounterCache
{
return
&
openAI403CounterCache
{
rdb
:
rdb
}
}
func
(
c
*
openAI403CounterCache
)
IncrementOpenAI403Count
(
ctx
context
.
Context
,
accountID
int64
,
windowMinutes
int
)
(
int64
,
error
)
{
key
:=
fmt
.
Sprintf
(
"%s%d"
,
openAI403CounterPrefix
,
accountID
)
ttlSeconds
:=
windowMinutes
*
60
if
ttlSeconds
<
60
{
ttlSeconds
=
60
}
result
,
err
:=
openAI403CounterIncrScript
.
Run
(
ctx
,
c
.
rdb
,
[]
string
{
key
},
ttlSeconds
)
.
Int64
()
if
err
!=
nil
{
return
0
,
fmt
.
Errorf
(
"increment openai 403 count: %w"
,
err
)
}
return
result
,
nil
}
func
(
c
*
openAI403CounterCache
)
ResetOpenAI403Count
(
ctx
context
.
Context
,
accountID
int64
)
error
{
key
:=
fmt
.
Sprintf
(
"%s%d"
,
openAI403CounterPrefix
,
accountID
)
return
c
.
rdb
.
Del
(
ctx
,
key
)
.
Err
()
}
backend/internal/repository/openai_oauth_service.go
View file @
5e060b22
...
...
@@ -2,6 +2,7 @@ package repository
import
(
"context"
"errors"
"net/http"
"net/url"
"strings"
...
...
@@ -53,6 +54,9 @@ func (s *openaiOAuthService) ExchangeCode(ctx context.Context, code, codeVerifie
Post
(
s
.
tokenURL
)
if
err
!=
nil
{
if
shouldReturnOpenAINoProxyHint
(
ctx
,
proxyURL
,
err
)
{
return
nil
,
newOpenAINoProxyHintError
(
err
)
}
return
nil
,
infraerrors
.
Newf
(
http
.
StatusBadGateway
,
"OPENAI_OAUTH_REQUEST_FAILED"
,
"request failed: %v"
,
err
)
}
...
...
@@ -98,6 +102,9 @@ func (s *openaiOAuthService) refreshTokenWithClientID(ctx context.Context, refre
Post
(
s
.
tokenURL
)
if
err
!=
nil
{
if
shouldReturnOpenAINoProxyHint
(
ctx
,
proxyURL
,
err
)
{
return
nil
,
newOpenAINoProxyHintError
(
err
)
}
return
nil
,
infraerrors
.
Newf
(
http
.
StatusBadGateway
,
"OPENAI_OAUTH_REQUEST_FAILED"
,
"request failed: %v"
,
err
)
}
...
...
@@ -114,3 +121,21 @@ func createOpenAIReqClient(proxyURL string) (*req.Client, error) {
Timeout
:
120
*
time
.
Second
,
})
}
func
shouldReturnOpenAINoProxyHint
(
ctx
context
.
Context
,
proxyURL
string
,
err
error
)
bool
{
if
strings
.
TrimSpace
(
proxyURL
)
!=
""
||
err
==
nil
{
return
false
}
if
ctx
!=
nil
&&
ctx
.
Err
()
!=
nil
{
return
false
}
return
!
errors
.
Is
(
err
,
context
.
Canceled
)
}
func
newOpenAINoProxyHintError
(
cause
error
)
error
{
return
infraerrors
.
New
(
http
.
StatusBadGateway
,
"OPENAI_OAUTH_PROXY_REQUIRED"
,
"OpenAI OAuth request failed: no proxy is configured and this server could not reach OpenAI directly. Select a proxy that can access OpenAI, then retry; if the authorization code has expired, regenerate the authorization URL."
,
)
.
WithCause
(
cause
)
}
Prev
1
2
3
4
5
6
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