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
377bffe2
Commit
377bffe2
authored
Feb 03, 2026
by
yangjianbo
Browse files
Merge branch 'main' into test
parents
99250ec5
31fe0178
Changes
235
Show whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/user_handler.go
View file @
377bffe2
...
...
@@ -277,3 +277,44 @@ func (h *UserHandler) GetUserUsage(c *gin.Context) {
response
.
Success
(
c
,
stats
)
}
// GetBalanceHistory handles getting user's balance/concurrency change history
// GET /api/v1/admin/users/:id/balance-history
// Query params:
// - type: filter by record type (balance, admin_balance, concurrency, admin_concurrency, subscription)
func
(
h
*
UserHandler
)
GetBalanceHistory
(
c
*
gin
.
Context
)
{
userID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid user ID"
)
return
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
codeType
:=
c
.
Query
(
"type"
)
codes
,
total
,
totalRecharged
,
err
:=
h
.
adminService
.
GetUserBalanceHistory
(
c
.
Request
.
Context
(),
userID
,
page
,
pageSize
,
codeType
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Convert to admin DTO (includes notes field for admin visibility)
out
:=
make
([]
dto
.
AdminRedeemCode
,
0
,
len
(
codes
))
for
i
:=
range
codes
{
out
=
append
(
out
,
*
dto
.
RedeemCodeFromServiceAdmin
(
&
codes
[
i
]))
}
// Custom response with total_recharged alongside pagination
pages
:=
int
((
total
+
int64
(
pageSize
)
-
1
)
/
int64
(
pageSize
))
if
pages
<
1
{
pages
=
1
}
response
.
Success
(
c
,
gin
.
H
{
"items"
:
out
,
"total"
:
total
,
"page"
:
page
,
"page_size"
:
pageSize
,
"pages"
:
pages
,
"total_recharged"
:
totalRecharged
,
})
}
backend/internal/handler/announcement_handler.go
0 → 100644
View file @
377bffe2
package
handler
import
(
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// AnnouncementHandler handles user announcement operations
type
AnnouncementHandler
struct
{
announcementService
*
service
.
AnnouncementService
}
// NewAnnouncementHandler creates a new user announcement handler
func
NewAnnouncementHandler
(
announcementService
*
service
.
AnnouncementService
)
*
AnnouncementHandler
{
return
&
AnnouncementHandler
{
announcementService
:
announcementService
,
}
}
// List handles listing announcements visible to current user
// GET /api/v1/announcements
func
(
h
*
AnnouncementHandler
)
List
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not found in context"
)
return
}
unreadOnly
:=
parseBoolQuery
(
c
.
Query
(
"unread_only"
))
items
,
err
:=
h
.
announcementService
.
ListForUser
(
c
.
Request
.
Context
(),
subject
.
UserID
,
unreadOnly
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
out
:=
make
([]
dto
.
UserAnnouncement
,
0
,
len
(
items
))
for
i
:=
range
items
{
out
=
append
(
out
,
*
dto
.
UserAnnouncementFromService
(
&
items
[
i
]))
}
response
.
Success
(
c
,
out
)
}
// MarkRead marks an announcement as read for current user
// POST /api/v1/announcements/:id/read
func
(
h
*
AnnouncementHandler
)
MarkRead
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not found in context"
)
return
}
announcementID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
||
announcementID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid announcement ID"
)
return
}
if
err
:=
h
.
announcementService
.
MarkRead
(
c
.
Request
.
Context
(),
subject
.
UserID
,
announcementID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"ok"
})
}
func
parseBoolQuery
(
v
string
)
bool
{
switch
strings
.
TrimSpace
(
strings
.
ToLower
(
v
))
{
case
"1"
,
"true"
,
"yes"
,
"y"
,
"on"
:
return
true
default
:
return
false
}
}
backend/internal/handler/auth_handler.go
View file @
377bffe2
package
handler
import
(
"log/slog"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
...
...
@@ -18,16 +20,20 @@ type AuthHandler struct {
userService
*
service
.
UserService
settingSvc
*
service
.
SettingService
promoService
*
service
.
PromoService
redeemService
*
service
.
RedeemService
totpService
*
service
.
TotpService
}
// NewAuthHandler creates a new AuthHandler
func
NewAuthHandler
(
cfg
*
config
.
Config
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
settingService
*
service
.
SettingService
,
promoService
*
service
.
PromoService
)
*
AuthHandler
{
func
NewAuthHandler
(
cfg
*
config
.
Config
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
settingService
*
service
.
SettingService
,
promoService
*
service
.
PromoService
,
redeemService
*
service
.
RedeemService
,
totpService
*
service
.
TotpService
)
*
AuthHandler
{
return
&
AuthHandler
{
cfg
:
cfg
,
authService
:
authService
,
userService
:
userService
,
settingSvc
:
settingService
,
promoService
:
promoService
,
redeemService
:
redeemService
,
totpService
:
totpService
,
}
}
...
...
@@ -38,6 +44,7 @@ type RegisterRequest struct {
VerifyCode
string
`json:"verify_code"`
TurnstileToken
string
`json:"turnstile_token"`
PromoCode
string
`json:"promo_code"`
// 注册优惠码
InvitationCode
string
`json:"invitation_code"`
// 邀请码
}
// SendVerifyCodeRequest 发送验证码请求
...
...
@@ -83,7 +90,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
}
}
token
,
user
,
err
:=
h
.
authService
.
RegisterWithVerification
(
c
.
Request
.
Context
(),
req
.
Email
,
req
.
Password
,
req
.
VerifyCode
,
req
.
PromoCode
)
token
,
user
,
err
:=
h
.
authService
.
RegisterWithVerification
(
c
.
Request
.
Context
(),
req
.
Email
,
req
.
Password
,
req
.
VerifyCode
,
req
.
PromoCode
,
req
.
InvitationCode
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
...
...
@@ -144,6 +151,100 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// Check if TOTP 2FA is enabled for this user
if
h
.
totpService
!=
nil
&&
h
.
settingSvc
.
IsTotpEnabled
(
c
.
Request
.
Context
())
&&
user
.
TotpEnabled
{
// Create a temporary login session for 2FA
tempToken
,
err
:=
h
.
totpService
.
CreateLoginSession
(
c
.
Request
.
Context
(),
user
.
ID
,
user
.
Email
)
if
err
!=
nil
{
response
.
InternalError
(
c
,
"Failed to create 2FA session"
)
return
}
response
.
Success
(
c
,
TotpLoginResponse
{
Requires2FA
:
true
,
TempToken
:
tempToken
,
UserEmailMasked
:
service
.
MaskEmail
(
user
.
Email
),
})
return
}
response
.
Success
(
c
,
AuthResponse
{
AccessToken
:
token
,
TokenType
:
"Bearer"
,
User
:
dto
.
UserFromService
(
user
),
})
}
// TotpLoginResponse represents the response when 2FA is required
type
TotpLoginResponse
struct
{
Requires2FA
bool
`json:"requires_2fa"`
TempToken
string
`json:"temp_token,omitempty"`
UserEmailMasked
string
`json:"user_email_masked,omitempty"`
}
// Login2FARequest represents the 2FA login request
type
Login2FARequest
struct
{
TempToken
string
`json:"temp_token" binding:"required"`
TotpCode
string
`json:"totp_code" binding:"required,len=6"`
}
// Login2FA completes the login with 2FA verification
// POST /api/v1/auth/login/2fa
func
(
h
*
AuthHandler
)
Login2FA
(
c
*
gin
.
Context
)
{
var
req
Login2FARequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
slog
.
Debug
(
"login_2fa_request"
,
"temp_token_len"
,
len
(
req
.
TempToken
),
"totp_code_len"
,
len
(
req
.
TotpCode
))
// Get the login session
session
,
err
:=
h
.
totpService
.
GetLoginSession
(
c
.
Request
.
Context
(),
req
.
TempToken
)
if
err
!=
nil
||
session
==
nil
{
tokenPrefix
:=
""
if
len
(
req
.
TempToken
)
>=
8
{
tokenPrefix
=
req
.
TempToken
[
:
8
]
}
slog
.
Debug
(
"login_2fa_session_invalid"
,
"temp_token_prefix"
,
tokenPrefix
,
"error"
,
err
)
response
.
BadRequest
(
c
,
"Invalid or expired 2FA session"
)
return
}
slog
.
Debug
(
"login_2fa_session_found"
,
"user_id"
,
session
.
UserID
,
"email"
,
session
.
Email
)
// Verify the TOTP code
if
err
:=
h
.
totpService
.
VerifyCode
(
c
.
Request
.
Context
(),
session
.
UserID
,
req
.
TotpCode
);
err
!=
nil
{
slog
.
Debug
(
"login_2fa_verify_failed"
,
"user_id"
,
session
.
UserID
,
"error"
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
}
// Delete the login session
_
=
h
.
totpService
.
DeleteLoginSession
(
c
.
Request
.
Context
(),
req
.
TempToken
)
// Get the user
user
,
err
:=
h
.
userService
.
GetByID
(
c
.
Request
.
Context
(),
session
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Generate the JWT token
token
,
err
:=
h
.
authService
.
GenerateToken
(
user
)
if
err
!=
nil
{
response
.
InternalError
(
c
,
"Failed to generate token"
)
return
}
response
.
Success
(
c
,
AuthResponse
{
AccessToken
:
token
,
TokenType
:
"Bearer"
,
...
...
@@ -247,3 +348,146 @@ func (h *AuthHandler) ValidatePromoCode(c *gin.Context) {
BonusAmount
:
promoCode
.
BonusAmount
,
})
}
// ValidateInvitationCodeRequest 验证邀请码请求
type
ValidateInvitationCodeRequest
struct
{
Code
string
`json:"code" binding:"required"`
}
// ValidateInvitationCodeResponse 验证邀请码响应
type
ValidateInvitationCodeResponse
struct
{
Valid
bool
`json:"valid"`
ErrorCode
string
`json:"error_code,omitempty"`
}
// ValidateInvitationCode 验证邀请码(公开接口,注册前调用)
// POST /api/v1/auth/validate-invitation-code
func
(
h
*
AuthHandler
)
ValidateInvitationCode
(
c
*
gin
.
Context
)
{
// 检查邀请码功能是否启用
if
h
.
settingSvc
==
nil
||
!
h
.
settingSvc
.
IsInvitationCodeEnabled
(
c
.
Request
.
Context
())
{
response
.
Success
(
c
,
ValidateInvitationCodeResponse
{
Valid
:
false
,
ErrorCode
:
"INVITATION_CODE_DISABLED"
,
})
return
}
var
req
ValidateInvitationCodeRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
// 验证邀请码
redeemCode
,
err
:=
h
.
redeemService
.
GetByCode
(
c
.
Request
.
Context
(),
req
.
Code
)
if
err
!=
nil
{
response
.
Success
(
c
,
ValidateInvitationCodeResponse
{
Valid
:
false
,
ErrorCode
:
"INVITATION_CODE_NOT_FOUND"
,
})
return
}
// 检查类型和状态
if
redeemCode
.
Type
!=
service
.
RedeemTypeInvitation
{
response
.
Success
(
c
,
ValidateInvitationCodeResponse
{
Valid
:
false
,
ErrorCode
:
"INVITATION_CODE_INVALID"
,
})
return
}
if
redeemCode
.
Status
!=
service
.
StatusUnused
{
response
.
Success
(
c
,
ValidateInvitationCodeResponse
{
Valid
:
false
,
ErrorCode
:
"INVITATION_CODE_USED"
,
})
return
}
response
.
Success
(
c
,
ValidateInvitationCodeResponse
{
Valid
:
true
,
})
}
// ForgotPasswordRequest 忘记密码请求
type
ForgotPasswordRequest
struct
{
Email
string
`json:"email" binding:"required,email"`
TurnstileToken
string
`json:"turnstile_token"`
}
// ForgotPasswordResponse 忘记密码响应
type
ForgotPasswordResponse
struct
{
Message
string
`json:"message"`
}
// ForgotPassword 请求密码重置
// POST /api/v1/auth/forgot-password
func
(
h
*
AuthHandler
)
ForgotPassword
(
c
*
gin
.
Context
)
{
var
req
ForgotPasswordRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
// Turnstile 验证
if
err
:=
h
.
authService
.
VerifyTurnstile
(
c
.
Request
.
Context
(),
req
.
TurnstileToken
,
ip
.
GetClientIP
(
c
));
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Build frontend base URL from request
scheme
:=
"https"
if
c
.
Request
.
TLS
==
nil
{
// Check X-Forwarded-Proto header (common in reverse proxy setups)
if
proto
:=
c
.
GetHeader
(
"X-Forwarded-Proto"
);
proto
!=
""
{
scheme
=
proto
}
else
{
scheme
=
"http"
}
}
frontendBaseURL
:=
scheme
+
"://"
+
c
.
Request
.
Host
// Request password reset (async)
// Note: This returns success even if email doesn't exist (to prevent enumeration)
if
err
:=
h
.
authService
.
RequestPasswordResetAsync
(
c
.
Request
.
Context
(),
req
.
Email
,
frontendBaseURL
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
ForgotPasswordResponse
{
Message
:
"If your email is registered, you will receive a password reset link shortly."
,
})
}
// ResetPasswordRequest 重置密码请求
type
ResetPasswordRequest
struct
{
Email
string
`json:"email" binding:"required,email"`
Token
string
`json:"token" binding:"required"`
NewPassword
string
`json:"new_password" binding:"required,min=6"`
}
// ResetPasswordResponse 重置密码响应
type
ResetPasswordResponse
struct
{
Message
string
`json:"message"`
}
// ResetPassword 重置密码
// POST /api/v1/auth/reset-password
func
(
h
*
AuthHandler
)
ResetPassword
(
c
*
gin
.
Context
)
{
var
req
ResetPasswordRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
// Reset password
if
err
:=
h
.
authService
.
ResetPassword
(
c
.
Request
.
Context
(),
req
.
Email
,
req
.
Token
,
req
.
NewPassword
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
ResetPasswordResponse
{
Message
:
"Your password has been reset successfully. You can now log in with your new password."
,
})
}
backend/internal/handler/dto/announcement.go
0 → 100644
View file @
377bffe2
package
dto
import
(
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type
Announcement
struct
{
ID
int64
`json:"id"`
Title
string
`json:"title"`
Content
string
`json:"content"`
Status
string
`json:"status"`
Targeting
service
.
AnnouncementTargeting
`json:"targeting"`
StartsAt
*
time
.
Time
`json:"starts_at,omitempty"`
EndsAt
*
time
.
Time
`json:"ends_at,omitempty"`
CreatedBy
*
int64
`json:"created_by,omitempty"`
UpdatedBy
*
int64
`json:"updated_by,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
type
UserAnnouncement
struct
{
ID
int64
`json:"id"`
Title
string
`json:"title"`
Content
string
`json:"content"`
StartsAt
*
time
.
Time
`json:"starts_at,omitempty"`
EndsAt
*
time
.
Time
`json:"ends_at,omitempty"`
ReadAt
*
time
.
Time
`json:"read_at,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
func
AnnouncementFromService
(
a
*
service
.
Announcement
)
*
Announcement
{
if
a
==
nil
{
return
nil
}
return
&
Announcement
{
ID
:
a
.
ID
,
Title
:
a
.
Title
,
Content
:
a
.
Content
,
Status
:
a
.
Status
,
Targeting
:
a
.
Targeting
,
StartsAt
:
a
.
StartsAt
,
EndsAt
:
a
.
EndsAt
,
CreatedBy
:
a
.
CreatedBy
,
UpdatedBy
:
a
.
UpdatedBy
,
CreatedAt
:
a
.
CreatedAt
,
UpdatedAt
:
a
.
UpdatedAt
,
}
}
func
UserAnnouncementFromService
(
a
*
service
.
UserAnnouncement
)
*
UserAnnouncement
{
if
a
==
nil
{
return
nil
}
return
&
UserAnnouncement
{
ID
:
a
.
Announcement
.
ID
,
Title
:
a
.
Announcement
.
Title
,
Content
:
a
.
Announcement
.
Content
,
StartsAt
:
a
.
Announcement
.
StartsAt
,
EndsAt
:
a
.
Announcement
.
EndsAt
,
ReadAt
:
a
.
ReadAt
,
CreatedAt
:
a
.
Announcement
.
CreatedAt
,
UpdatedAt
:
a
.
Announcement
.
UpdatedAt
,
}
}
backend/internal/handler/dto/mappers.go
View file @
377bffe2
...
...
@@ -208,6 +208,17 @@ func AccountFromServiceShallow(a *service.Account) *Account {
}
}
if
scopeLimits
:=
a
.
GetAntigravityScopeRateLimits
();
len
(
scopeLimits
)
>
0
{
out
.
ScopeRateLimits
=
make
(
map
[
string
]
ScopeRateLimitInfo
,
len
(
scopeLimits
))
now
:=
time
.
Now
()
for
scope
,
remainingSec
:=
range
scopeLimits
{
out
.
ScopeRateLimits
[
scope
]
=
ScopeRateLimitInfo
{
ResetAt
:
now
.
Add
(
time
.
Duration
(
remainingSec
)
*
time
.
Second
),
RemainingSec
:
remainingSec
,
}
}
}
return
out
}
...
...
@@ -325,7 +336,7 @@ func RedeemCodeFromServiceAdmin(rc *service.RedeemCode) *AdminRedeemCode {
}
func
redeemCodeFromServiceBase
(
rc
*
service
.
RedeemCode
)
RedeemCode
{
return
RedeemCode
{
out
:=
RedeemCode
{
ID
:
rc
.
ID
,
Code
:
rc
.
Code
,
Type
:
rc
.
Type
,
...
...
@@ -339,6 +350,14 @@ func redeemCodeFromServiceBase(rc *service.RedeemCode) RedeemCode {
User
:
UserFromServiceShallow
(
rc
.
User
),
Group
:
GroupFromServiceShallow
(
rc
.
Group
),
}
// For admin_balance/admin_concurrency types, include notes so users can see
// why they were charged or credited by admin
if
(
rc
.
Type
==
"admin_balance"
||
rc
.
Type
==
"admin_concurrency"
)
&&
rc
.
Notes
!=
""
{
out
.
Notes
=
&
rc
.
Notes
}
return
out
}
// AccountSummaryFromService returns a minimal AccountSummary for usage log display.
...
...
@@ -362,6 +381,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
AccountID
:
l
.
AccountID
,
RequestID
:
l
.
RequestID
,
Model
:
l
.
Model
,
ReasoningEffort
:
l
.
ReasoningEffort
,
GroupID
:
l
.
GroupID
,
SubscriptionID
:
l
.
SubscriptionID
,
InputTokens
:
l
.
InputTokens
,
...
...
backend/internal/handler/dto/settings.go
View file @
377bffe2
...
...
@@ -5,6 +5,10 @@ type SystemSettings struct {
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
InvitationCodeEnabled
bool
`json:"invitation_code_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
// TOTP 双因素认证
TotpEncryptionKeyConfigured
bool
`json:"totp_encryption_key_configured"`
// TOTP 加密密钥是否已配置
SMTPHost
string
`json:"smtp_host"`
SMTPPort
int
`json:"smtp_port"`
...
...
@@ -31,6 +35,8 @@ type SystemSettings struct {
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
...
...
@@ -57,6 +63,9 @@ type PublicSettings struct {
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
InvitationCodeEnabled
bool
`json:"invitation_code_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
// TOTP 双因素认证
TurnstileEnabled
bool
`json:"turnstile_enabled"`
TurnstileSiteKey
string
`json:"turnstile_site_key"`
SiteName
string
`json:"site_name"`
...
...
@@ -67,6 +76,8 @@ type PublicSettings struct {
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
Version
string
`json:"version"`
}
...
...
backend/internal/handler/dto/types.go
View file @
377bffe2
...
...
@@ -2,6 +2,11 @@ package dto
import
"time"
type
ScopeRateLimitInfo
struct
{
ResetAt
time
.
Time
`json:"reset_at"`
RemainingSec
int64
`json:"remaining_sec"`
}
type
User
struct
{
ID
int64
`json:"id"`
Email
string
`json:"email"`
...
...
@@ -114,6 +119,9 @@ type Account struct {
RateLimitResetAt
*
time
.
Time
`json:"rate_limit_reset_at"`
OverloadUntil
*
time
.
Time
`json:"overload_until"`
// Antigravity scope 级限流状态(从 extra 提取)
ScopeRateLimits
map
[
string
]
ScopeRateLimitInfo
`json:"scope_rate_limits,omitempty"`
TempUnschedulableUntil
*
time
.
Time
`json:"temp_unschedulable_until"`
TempUnschedulableReason
string
`json:"temp_unschedulable_reason"`
...
...
@@ -204,6 +212,10 @@ type RedeemCode struct {
GroupID
*
int64
`json:"group_id"`
ValidityDays
int
`json:"validity_days"`
// Notes is only populated for admin_balance/admin_concurrency types
// so users can see why they were charged or credited
Notes
*
string
`json:"notes,omitempty"`
User
*
User
`json:"user,omitempty"`
Group
*
Group
`json:"group,omitempty"`
}
...
...
@@ -224,6 +236,9 @@ type UsageLog struct {
AccountID
int64
`json:"account_id"`
RequestID
string
`json:"request_id"`
Model
string
`json:"model"`
// ReasoningEffort is the request's reasoning effort level (OpenAI Responses API).
// nil means not provided / not applicable.
ReasoningEffort
*
string
`json:"reasoning_effort,omitempty"`
GroupID
*
int64
`json:"group_id"`
SubscriptionID
*
int64
`json:"subscription_id"`
...
...
backend/internal/handler/gateway_handler.go
View file @
377bffe2
...
...
@@ -30,6 +30,7 @@ type GatewayHandler struct {
antigravityGatewayService
*
service
.
AntigravityGatewayService
userService
*
service
.
UserService
billingCacheService
*
service
.
BillingCacheService
usageService
*
service
.
UsageService
concurrencyHelper
*
ConcurrencyHelper
maxAccountSwitches
int
maxAccountSwitchesGemini
int
...
...
@@ -44,6 +45,7 @@ func NewGatewayHandler(
userService
*
service
.
UserService
,
concurrencyService
*
service
.
ConcurrencyService
,
billingCacheService
*
service
.
BillingCacheService
,
usageService
*
service
.
UsageService
,
cfg
*
config
.
Config
,
)
*
GatewayHandler
{
pingInterval
:=
time
.
Duration
(
0
)
...
...
@@ -64,6 +66,7 @@ func NewGatewayHandler(
antigravityGatewayService
:
antigravityGatewayService
,
userService
:
userService
,
billingCacheService
:
billingCacheService
,
usageService
:
usageService
,
concurrencyHelper
:
NewConcurrencyHelper
(
concurrencyService
,
SSEPingFormatClaude
,
pingInterval
),
maxAccountSwitches
:
maxAccountSwitches
,
maxAccountSwitchesGemini
:
maxAccountSwitchesGemini
,
...
...
@@ -537,7 +540,7 @@ func (h *GatewayHandler) AntigravityModels(c *gin.Context) {
})
}
// Usage handles getting account balance for CC Switch integration
// Usage handles getting account balance
and usage statistics
for CC Switch integration
// GET /v1/usage
func
(
h
*
GatewayHandler
)
Usage
(
c
*
gin
.
Context
)
{
apiKey
,
ok
:=
middleware2
.
GetAPIKeyFromContext
(
c
)
...
...
@@ -552,7 +555,40 @@ func (h *GatewayHandler) Usage(c *gin.Context) {
return
}
// 订阅模式:返回订阅限额信息
// Best-effort: 获取用量统计,失败不影响基础响应
var
usageData
gin
.
H
if
h
.
usageService
!=
nil
{
dashStats
,
err
:=
h
.
usageService
.
GetUserDashboardStats
(
c
.
Request
.
Context
(),
subject
.
UserID
)
if
err
==
nil
&&
dashStats
!=
nil
{
usageData
=
gin
.
H
{
"today"
:
gin
.
H
{
"requests"
:
dashStats
.
TodayRequests
,
"input_tokens"
:
dashStats
.
TodayInputTokens
,
"output_tokens"
:
dashStats
.
TodayOutputTokens
,
"cache_creation_tokens"
:
dashStats
.
TodayCacheCreationTokens
,
"cache_read_tokens"
:
dashStats
.
TodayCacheReadTokens
,
"total_tokens"
:
dashStats
.
TodayTokens
,
"cost"
:
dashStats
.
TodayCost
,
"actual_cost"
:
dashStats
.
TodayActualCost
,
},
"total"
:
gin
.
H
{
"requests"
:
dashStats
.
TotalRequests
,
"input_tokens"
:
dashStats
.
TotalInputTokens
,
"output_tokens"
:
dashStats
.
TotalOutputTokens
,
"cache_creation_tokens"
:
dashStats
.
TotalCacheCreationTokens
,
"cache_read_tokens"
:
dashStats
.
TotalCacheReadTokens
,
"total_tokens"
:
dashStats
.
TotalTokens
,
"cost"
:
dashStats
.
TotalCost
,
"actual_cost"
:
dashStats
.
TotalActualCost
,
},
"average_duration_ms"
:
dashStats
.
AverageDurationMs
,
"rpm"
:
dashStats
.
Rpm
,
"tpm"
:
dashStats
.
Tpm
,
}
}
}
// 订阅模式:返回订阅限额信息 + 用量统计
if
apiKey
.
Group
!=
nil
&&
apiKey
.
Group
.
IsSubscriptionType
()
{
subscription
,
ok
:=
middleware2
.
GetSubscriptionFromContext
(
c
)
if
!
ok
{
...
...
@@ -561,28 +597,46 @@ func (h *GatewayHandler) Usage(c *gin.Context) {
}
remaining
:=
h
.
calculateSubscriptionRemaining
(
apiKey
.
Group
,
subscription
)
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
resp
:=
gin
.
H
{
"isValid"
:
true
,
"planName"
:
apiKey
.
Group
.
Name
,
"remaining"
:
remaining
,
"unit"
:
"USD"
,
})
"subscription"
:
gin
.
H
{
"daily_usage_usd"
:
subscription
.
DailyUsageUSD
,
"weekly_usage_usd"
:
subscription
.
WeeklyUsageUSD
,
"monthly_usage_usd"
:
subscription
.
MonthlyUsageUSD
,
"daily_limit_usd"
:
apiKey
.
Group
.
DailyLimitUSD
,
"weekly_limit_usd"
:
apiKey
.
Group
.
WeeklyLimitUSD
,
"monthly_limit_usd"
:
apiKey
.
Group
.
MonthlyLimitUSD
,
"expires_at"
:
subscription
.
ExpiresAt
,
},
}
if
usageData
!=
nil
{
resp
[
"usage"
]
=
usageData
}
c
.
JSON
(
http
.
StatusOK
,
resp
)
return
}
// 余额模式:返回钱包余额
// 余额模式:返回钱包余额
+ 用量统计
latestUser
,
err
:=
h
.
userService
.
GetByID
(
c
.
Request
.
Context
(),
subject
.
UserID
)
if
err
!=
nil
{
h
.
errorResponse
(
c
,
http
.
StatusInternalServerError
,
"api_error"
,
"Failed to get user info"
)
return
}
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
resp
:=
gin
.
H
{
"isValid"
:
true
,
"planName"
:
"钱包余额"
,
"remaining"
:
latestUser
.
Balance
,
"unit"
:
"USD"
,
})
"balance"
:
latestUser
.
Balance
,
}
if
usageData
!=
nil
{
resp
[
"usage"
]
=
usageData
}
c
.
JSON
(
http
.
StatusOK
,
resp
)
}
// calculateSubscriptionRemaining 计算订阅剩余可用额度
...
...
@@ -738,6 +792,9 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
return
}
// 检查是否为 Claude Code 客户端,设置到 context 中
SetClaudeCodeClientContext
(
c
,
body
)
setOpsRequestContext
(
c
,
""
,
false
,
body
)
parsedReq
,
err
:=
service
.
ParseGatewayRequest
(
body
)
...
...
backend/internal/handler/gemini_cli_session_test.go
0 → 100644
View file @
377bffe2
//go:build unit
package
handler
import
(
"crypto/sha256"
"encoding/hex"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func
TestExtractGeminiCLISessionHash
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
body
string
privilegedUserID
string
wantEmpty
bool
wantHash
string
}{
{
name
:
"with privileged-user-id and tmp dir"
,
body
:
`{"contents":[{"parts":[{"text":"The project's temporary directory is: /Users/ianshaw/.gemini/tmp/f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740"}]}]}`
,
privilegedUserID
:
"90785f52-8bbe-4b17-b111-a1ddea1636c3"
,
wantEmpty
:
false
,
wantHash
:
func
()
string
{
combined
:=
"90785f52-8bbe-4b17-b111-a1ddea1636c3:f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740"
hash
:=
sha256
.
Sum256
([]
byte
(
combined
))
return
hex
.
EncodeToString
(
hash
[
:
])
}(),
},
{
name
:
"without privileged-user-id but with tmp dir"
,
body
:
`{"contents":[{"parts":[{"text":"The project's temporary directory is: /Users/ianshaw/.gemini/tmp/f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740"}]}]}`
,
privilegedUserID
:
""
,
wantEmpty
:
false
,
wantHash
:
"f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740"
,
},
{
name
:
"without tmp dir"
,
body
:
`{"contents":[{"parts":[{"text":"Hello world"}]}]}`
,
privilegedUserID
:
"90785f52-8bbe-4b17-b111-a1ddea1636c3"
,
wantEmpty
:
true
,
},
{
name
:
"empty body"
,
body
:
""
,
privilegedUserID
:
"90785f52-8bbe-4b17-b111-a1ddea1636c3"
,
wantEmpty
:
true
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
// 创建测试上下文
w
:=
httptest
.
NewRecorder
()
c
,
_
:=
gin
.
CreateTestContext
(
w
)
c
.
Request
=
httptest
.
NewRequest
(
"POST"
,
"/test"
,
nil
)
if
tt
.
privilegedUserID
!=
""
{
c
.
Request
.
Header
.
Set
(
"x-gemini-api-privileged-user-id"
,
tt
.
privilegedUserID
)
}
// 调用函数
result
:=
extractGeminiCLISessionHash
(
c
,
[]
byte
(
tt
.
body
))
// 验证结果
if
tt
.
wantEmpty
{
require
.
Empty
(
t
,
result
,
"expected empty session hash"
)
}
else
{
require
.
NotEmpty
(
t
,
result
,
"expected non-empty session hash"
)
require
.
Equal
(
t
,
tt
.
wantHash
,
result
,
"session hash mismatch"
)
}
})
}
}
func
TestGeminiCLITmpDirRegex
(
t
*
testing
.
T
)
{
tests
:=
[]
struct
{
name
string
input
string
wantMatch
bool
wantHash
string
}{
{
name
:
"valid tmp dir path"
,
input
:
"/Users/ianshaw/.gemini/tmp/f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740"
,
wantMatch
:
true
,
wantHash
:
"f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740"
,
},
{
name
:
"valid tmp dir path in text"
,
input
:
"The project's temporary directory is: /Users/ianshaw/.gemini/tmp/f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740
\n
Other text"
,
wantMatch
:
true
,
wantHash
:
"f7851b009ed314d1baee62e83115f486160283f4a55a582d89fdac8b9fe3b740"
,
},
{
name
:
"invalid hash length"
,
input
:
"/Users/ianshaw/.gemini/tmp/abc123"
,
wantMatch
:
false
,
},
{
name
:
"no tmp dir"
,
input
:
"Hello world"
,
wantMatch
:
false
,
},
}
for
_
,
tt
:=
range
tests
{
t
.
Run
(
tt
.
name
,
func
(
t
*
testing
.
T
)
{
match
:=
geminiCLITmpDirRegex
.
FindStringSubmatch
(
tt
.
input
)
if
tt
.
wantMatch
{
require
.
NotNil
(
t
,
match
,
"expected regex to match"
)
require
.
Len
(
t
,
match
,
2
,
"expected 2 capture groups"
)
require
.
Equal
(
t
,
tt
.
wantHash
,
match
[
1
],
"hash mismatch"
)
}
else
{
require
.
Nil
(
t
,
match
,
"expected regex not to match"
)
}
})
}
}
backend/internal/handler/gemini_v1beta_handler.go
View file @
377bffe2
package
handler
import
(
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log"
"net/http"
"regexp"
"strings"
"time"
...
...
@@ -19,6 +23,17 @@ import (
"github.com/gin-gonic/gin"
)
// geminiCLITmpDirRegex 用于从 Gemini CLI 请求体中提取 tmp 目录的哈希值
// 匹配格式: /Users/xxx/.gemini/tmp/[64位十六进制哈希]
var
geminiCLITmpDirRegex
=
regexp
.
MustCompile
(
`/\.gemini/tmp/([A-Fa-f0-9]{64})`
)
func
isGeminiCLIRequest
(
c
*
gin
.
Context
,
body
[]
byte
)
bool
{
if
strings
.
TrimSpace
(
c
.
GetHeader
(
"x-gemini-api-privileged-user-id"
))
!=
""
{
return
true
}
return
geminiCLITmpDirRegex
.
Match
(
body
)
}
// GeminiV1BetaListModels proxies:
// GET /v1beta/models
func
(
h
*
GatewayHandler
)
GeminiV1BetaListModels
(
c
*
gin
.
Context
)
{
...
...
@@ -214,12 +229,26 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
}
// 3) select account (sticky session based on request body)
// 优先使用 Gemini CLI 的会话标识(privileged-user-id + tmp 目录哈希)
sessionHash
:=
extractGeminiCLISessionHash
(
c
,
body
)
if
sessionHash
==
""
{
// Fallback: 使用通用的会话哈希生成逻辑(适用于其他客户端)
parsedReq
,
_
:=
service
.
ParseGatewayRequest
(
body
)
sessionHash
:=
h
.
gatewayService
.
GenerateSessionHash
(
parsedReq
)
sessionHash
=
h
.
gatewayService
.
GenerateSessionHash
(
parsedReq
)
}
sessionKey
:=
sessionHash
if
sessionHash
!=
""
{
sessionKey
=
"gemini:"
+
sessionHash
}
// 查询粘性会话绑定的账号 ID(用于检测账号切换)
var
sessionBoundAccountID
int64
if
sessionKey
!=
""
{
sessionBoundAccountID
,
_
=
h
.
gatewayService
.
GetCachedSessionAccountID
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
sessionKey
)
}
isCLI
:=
isGeminiCLIRequest
(
c
,
body
)
cleanedForUnknownBinding
:=
false
maxAccountSwitches
:=
h
.
maxAccountSwitchesGemini
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
...
...
@@ -238,6 +267,24 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
// 检测账号切换:如果粘性会话绑定的账号与当前选择的账号不同,清除 thoughtSignature
// 注意:Gemini 原生 API 的 thoughtSignature 与具体上游账号强相关;跨账号透传会导致 400。
if
sessionBoundAccountID
>
0
&&
sessionBoundAccountID
!=
account
.
ID
{
log
.
Printf
(
"[Gemini] Sticky session account switched: %d -> %d, cleaning thoughtSignature"
,
sessionBoundAccountID
,
account
.
ID
)
body
=
service
.
CleanGeminiNativeThoughtSignatures
(
body
)
sessionBoundAccountID
=
account
.
ID
}
else
if
sessionKey
!=
""
&&
sessionBoundAccountID
==
0
&&
isCLI
&&
!
cleanedForUnknownBinding
&&
bytes
.
Contains
(
body
,
[]
byte
(
`"thoughtSignature"`
))
{
// 无缓存绑定但请求里已有 thoughtSignature:常见于缓存丢失/TTL 过期后,CLI 继续携带旧签名。
// 为避免第一次转发就 400,这里做一次确定性清理,让新账号重新生成签名链路。
log
.
Printf
(
"[Gemini] Sticky session binding missing for CLI request, cleaning thoughtSignature proactively"
)
body
=
service
.
CleanGeminiNativeThoughtSignatures
(
body
)
cleanedForUnknownBinding
=
true
sessionBoundAccountID
=
account
.
ID
}
else
if
sessionBoundAccountID
==
0
{
// 记录本次请求中首次选择到的账号,便于同一请求内 failover 时检测切换。
sessionBoundAccountID
=
account
.
ID
}
// 4) account concurrency slot
accountReleaseFunc
:=
selection
.
ReleaseFunc
if
!
selection
.
Acquired
{
...
...
@@ -319,11 +366,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
userAgent
:=
c
.
GetHeader
(
"User-Agent"
)
clientIP
:=
ip
.
GetClientIP
(
c
)
// 6) record usage async
// 6) record usage async
(Gemini 使用长上下文双倍计费)
go
func
(
result
*
service
.
ForwardResult
,
usedAccount
*
service
.
Account
,
ua
,
ip
string
)
{
ctx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
10
*
time
.
Second
)
defer
cancel
()
if
err
:=
h
.
gatewayService
.
RecordUsage
(
ctx
,
&
service
.
RecordUsageInput
{
if
err
:=
h
.
gatewayService
.
RecordUsageWithLongContext
(
ctx
,
&
service
.
RecordUsageLongContextInput
{
Result
:
result
,
APIKey
:
apiKey
,
User
:
apiKey
.
User
,
...
...
@@ -331,6 +379,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
Subscription
:
subscription
,
UserAgent
:
ua
,
IPAddress
:
ip
,
LongContextThreshold
:
200000
,
// Gemini 200K 阈值
LongContextMultiplier
:
2.0
,
// 超出部分双倍计费
});
err
!=
nil
{
log
.
Printf
(
"Record usage failed: %v"
,
err
)
}
...
...
@@ -433,3 +483,38 @@ func shouldFallbackGeminiModels(res *service.UpstreamHTTPResult) bool {
}
return
false
}
// extractGeminiCLISessionHash 从 Gemini CLI 请求中提取会话标识。
// 组合 x-gemini-api-privileged-user-id header 和请求体中的 tmp 目录哈希。
//
// 会话标识生成策略:
// 1. 从请求体中提取 tmp 目录哈希(64位十六进制)
// 2. 从 header 中提取 privileged-user-id(UUID)
// 3. 组合两者生成 SHA256 哈希作为最终的会话标识
//
// 如果找不到 tmp 目录哈希,返回空字符串(不使用粘性会话)。
//
// extractGeminiCLISessionHash extracts session identifier from Gemini CLI requests.
// Combines x-gemini-api-privileged-user-id header with tmp directory hash from request body.
func
extractGeminiCLISessionHash
(
c
*
gin
.
Context
,
body
[]
byte
)
string
{
// 1. 从请求体中提取 tmp 目录哈希
match
:=
geminiCLITmpDirRegex
.
FindSubmatch
(
body
)
if
len
(
match
)
<
2
{
return
""
// 没有找到 tmp 目录,不使用粘性会话
}
tmpDirHash
:=
string
(
match
[
1
])
// 2. 提取 privileged-user-id
privilegedUserID
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"x-gemini-api-privileged-user-id"
))
// 3. 组合生成最终的 session hash
if
privilegedUserID
!=
""
{
// 组合两个标识符:privileged-user-id + tmp 目录哈希
combined
:=
privilegedUserID
+
":"
+
tmpDirHash
hash
:=
sha256
.
Sum256
([]
byte
(
combined
))
return
hex
.
EncodeToString
(
hash
[
:
])
}
// 如果没有 privileged-user-id,直接使用 tmp 目录哈希
return
tmpDirHash
}
backend/internal/handler/handler.go
View file @
377bffe2
...
...
@@ -10,6 +10,7 @@ type AdminHandlers struct {
User
*
admin
.
UserHandler
Group
*
admin
.
GroupHandler
Account
*
admin
.
AccountHandler
Announcement
*
admin
.
AnnouncementHandler
OAuth
*
admin
.
OAuthHandler
OpenAIOAuth
*
admin
.
OpenAIOAuthHandler
GeminiOAuth
*
admin
.
GeminiOAuthHandler
...
...
@@ -33,11 +34,13 @@ type Handlers struct {
Usage
*
UsageHandler
Redeem
*
RedeemHandler
Subscription
*
SubscriptionHandler
Announcement
*
AnnouncementHandler
Admin
*
AdminHandlers
Gateway
*
GatewayHandler
OpenAIGateway
*
OpenAIGatewayHandler
SoraGateway
*
SoraGatewayHandler
Setting
*
SettingHandler
Totp
*
TotpHandler
}
// BuildInfo contains build-time information
...
...
backend/internal/handler/ops_error_logger.go
View file @
377bffe2
...
...
@@ -905,7 +905,7 @@ func classifyOpsIsRetryable(errType string, statusCode int) bool {
func
classifyOpsIsBusinessLimited
(
errType
,
phase
,
code
string
,
status
int
,
message
string
)
bool
{
switch
strings
.
TrimSpace
(
code
)
{
case
"INSUFFICIENT_BALANCE"
,
"USAGE_LIMIT_EXCEEDED"
,
"SUBSCRIPTION_NOT_FOUND"
,
"SUBSCRIPTION_INVALID"
:
case
"INSUFFICIENT_BALANCE"
,
"USAGE_LIMIT_EXCEEDED"
,
"SUBSCRIPTION_NOT_FOUND"
,
"SUBSCRIPTION_INVALID"
,
"USER_INACTIVE"
:
return
true
}
if
phase
==
"billing"
||
phase
==
"concurrency"
{
...
...
@@ -1011,5 +1011,12 @@ func shouldSkipOpsErrorLog(ctx context.Context, ops *service.OpsService, message
}
}
// Check if invalid/missing API key errors should be ignored (user misconfiguration)
if
settings
.
IgnoreInvalidApiKeyErrors
{
if
strings
.
Contains
(
bodyLower
,
"invalid_api_key"
)
||
strings
.
Contains
(
bodyLower
,
"api_key_required"
)
{
return
true
}
}
return
false
}
backend/internal/handler/setting_handler.go
View file @
377bffe2
...
...
@@ -35,6 +35,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
settings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
settings
.
PasswordResetEnabled
,
InvitationCodeEnabled
:
settings
.
InvitationCodeEnabled
,
TotpEnabled
:
settings
.
TotpEnabled
,
TurnstileEnabled
:
settings
.
TurnstileEnabled
,
TurnstileSiteKey
:
settings
.
TurnstileSiteKey
,
SiteName
:
settings
.
SiteName
,
...
...
@@ -45,6 +48,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
DocURL
:
settings
.
DocURL
,
HomeContent
:
settings
.
HomeContent
,
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
Version
:
h
.
version
,
})
...
...
backend/internal/handler/totp_handler.go
0 → 100644
View file @
377bffe2
package
handler
import
(
"github.com/gin-gonic/gin"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// TotpHandler handles TOTP-related requests
type
TotpHandler
struct
{
totpService
*
service
.
TotpService
}
// NewTotpHandler creates a new TotpHandler
func
NewTotpHandler
(
totpService
*
service
.
TotpService
)
*
TotpHandler
{
return
&
TotpHandler
{
totpService
:
totpService
,
}
}
// TotpStatusResponse represents the TOTP status response
type
TotpStatusResponse
struct
{
Enabled
bool
`json:"enabled"`
EnabledAt
*
int64
`json:"enabled_at,omitempty"`
// Unix timestamp
FeatureEnabled
bool
`json:"feature_enabled"`
}
// GetStatus returns the TOTP status for the current user
// GET /api/v1/user/totp/status
func
(
h
*
TotpHandler
)
GetStatus
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not authenticated"
)
return
}
status
,
err
:=
h
.
totpService
.
GetStatus
(
c
.
Request
.
Context
(),
subject
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
resp
:=
TotpStatusResponse
{
Enabled
:
status
.
Enabled
,
FeatureEnabled
:
status
.
FeatureEnabled
,
}
if
status
.
EnabledAt
!=
nil
{
ts
:=
status
.
EnabledAt
.
Unix
()
resp
.
EnabledAt
=
&
ts
}
response
.
Success
(
c
,
resp
)
}
// TotpSetupRequest represents the request to initiate TOTP setup
type
TotpSetupRequest
struct
{
EmailCode
string
`json:"email_code"`
Password
string
`json:"password"`
}
// TotpSetupResponse represents the TOTP setup response
type
TotpSetupResponse
struct
{
Secret
string
`json:"secret"`
QRCodeURL
string
`json:"qr_code_url"`
SetupToken
string
`json:"setup_token"`
Countdown
int
`json:"countdown"`
}
// InitiateSetup starts the TOTP setup process
// POST /api/v1/user/totp/setup
func
(
h
*
TotpHandler
)
InitiateSetup
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not authenticated"
)
return
}
var
req
TotpSetupRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
// Allow empty body (optional params)
req
=
TotpSetupRequest
{}
}
result
,
err
:=
h
.
totpService
.
InitiateSetup
(
c
.
Request
.
Context
(),
subject
.
UserID
,
req
.
EmailCode
,
req
.
Password
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
TotpSetupResponse
{
Secret
:
result
.
Secret
,
QRCodeURL
:
result
.
QRCodeURL
,
SetupToken
:
result
.
SetupToken
,
Countdown
:
result
.
Countdown
,
})
}
// TotpEnableRequest represents the request to enable TOTP
type
TotpEnableRequest
struct
{
TotpCode
string
`json:"totp_code" binding:"required,len=6"`
SetupToken
string
`json:"setup_token" binding:"required"`
}
// Enable completes the TOTP setup
// POST /api/v1/user/totp/enable
func
(
h
*
TotpHandler
)
Enable
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not authenticated"
)
return
}
var
req
TotpEnableRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
err
:=
h
.
totpService
.
CompleteSetup
(
c
.
Request
.
Context
(),
subject
.
UserID
,
req
.
TotpCode
,
req
.
SetupToken
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"success"
:
true
})
}
// TotpDisableRequest represents the request to disable TOTP
type
TotpDisableRequest
struct
{
EmailCode
string
`json:"email_code"`
Password
string
`json:"password"`
}
// Disable disables TOTP for the current user
// POST /api/v1/user/totp/disable
func
(
h
*
TotpHandler
)
Disable
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not authenticated"
)
return
}
var
req
TotpDisableRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
err
:=
h
.
totpService
.
Disable
(
c
.
Request
.
Context
(),
subject
.
UserID
,
req
.
EmailCode
,
req
.
Password
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"success"
:
true
})
}
// GetVerificationMethod returns the verification method for TOTP operations
// GET /api/v1/user/totp/verification-method
func
(
h
*
TotpHandler
)
GetVerificationMethod
(
c
*
gin
.
Context
)
{
method
:=
h
.
totpService
.
GetVerificationMethod
(
c
.
Request
.
Context
())
response
.
Success
(
c
,
method
)
}
// SendVerifyCode sends an email verification code for TOTP operations
// POST /api/v1/user/totp/send-code
func
(
h
*
TotpHandler
)
SendVerifyCode
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not authenticated"
)
return
}
if
err
:=
h
.
totpService
.
SendVerifyCode
(
c
.
Request
.
Context
(),
subject
.
UserID
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"success"
:
true
})
}
backend/internal/handler/wire.go
View file @
377bffe2
...
...
@@ -13,6 +13,7 @@ func ProvideAdminHandlers(
userHandler
*
admin
.
UserHandler
,
groupHandler
*
admin
.
GroupHandler
,
accountHandler
*
admin
.
AccountHandler
,
announcementHandler
*
admin
.
AnnouncementHandler
,
oauthHandler
*
admin
.
OAuthHandler
,
openaiOAuthHandler
*
admin
.
OpenAIOAuthHandler
,
geminiOAuthHandler
*
admin
.
GeminiOAuthHandler
,
...
...
@@ -32,6 +33,7 @@ func ProvideAdminHandlers(
User
:
userHandler
,
Group
:
groupHandler
,
Account
:
accountHandler
,
Announcement
:
announcementHandler
,
OAuth
:
oauthHandler
,
OpenAIOAuth
:
openaiOAuthHandler
,
GeminiOAuth
:
geminiOAuthHandler
,
...
...
@@ -66,11 +68,13 @@ func ProvideHandlers(
usageHandler
*
UsageHandler
,
redeemHandler
*
RedeemHandler
,
subscriptionHandler
*
SubscriptionHandler
,
announcementHandler
*
AnnouncementHandler
,
adminHandlers
*
AdminHandlers
,
gatewayHandler
*
GatewayHandler
,
openaiGatewayHandler
*
OpenAIGatewayHandler
,
soraGatewayHandler
*
SoraGatewayHandler
,
settingHandler
*
SettingHandler
,
totpHandler
*
TotpHandler
,
)
*
Handlers
{
return
&
Handlers
{
Auth
:
authHandler
,
...
...
@@ -79,11 +83,13 @@ func ProvideHandlers(
Usage
:
usageHandler
,
Redeem
:
redeemHandler
,
Subscription
:
subscriptionHandler
,
Announcement
:
announcementHandler
,
Admin
:
adminHandlers
,
Gateway
:
gatewayHandler
,
OpenAIGateway
:
openaiGatewayHandler
,
SoraGateway
:
soraGatewayHandler
,
Setting
:
settingHandler
,
Totp
:
totpHandler
,
}
}
...
...
@@ -96,9 +102,11 @@ var ProviderSet = wire.NewSet(
NewUsageHandler
,
NewRedeemHandler
,
NewSubscriptionHandler
,
NewAnnouncementHandler
,
NewGatewayHandler
,
NewOpenAIGatewayHandler
,
NewSoraGatewayHandler
,
NewTotpHandler
,
ProvideSettingHandler
,
// Admin handlers
...
...
@@ -106,6 +114,7 @@ var ProviderSet = wire.NewSet(
admin
.
NewUserHandler
,
admin
.
NewGroupHandler
,
admin
.
NewAccountHandler
,
admin
.
NewAnnouncementHandler
,
admin
.
NewOAuthHandler
,
admin
.
NewOpenAIOAuthHandler
,
admin
.
NewGeminiOAuthHandler
,
...
...
backend/internal/pkg/antigravity/oauth.go
View file @
377bffe2
...
...
@@ -33,7 +33,7 @@ const (
"https://www.googleapis.com/auth/experimentsandconfigs"
// User-Agent(与 Antigravity-Manager 保持一致)
UserAgent
=
"antigravity/1.1
1.9
windows/amd64"
UserAgent
=
"antigravity/1.1
5.8
windows/amd64"
// Session 过期时间
SessionTTL
=
30
*
time
.
Minute
...
...
backend/internal/pkg/antigravity/request_transformer.go
View file @
377bffe2
...
...
@@ -367,8 +367,10 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
Text
:
block
.
Thinking
,
Thought
:
true
,
}
// 保留原有 signature(Claude 模型需要有效的 signature)
if
block
.
Signature
!=
""
{
// signature 处理:
// - Claude 模型(allowDummyThought=false):必须是上游返回的真实 signature(dummy 视为缺失)
// - Gemini 模型(allowDummyThought=true):优先透传真实 signature,缺失时使用 dummy signature
if
block
.
Signature
!=
""
&&
(
allowDummyThought
||
block
.
Signature
!=
dummyThoughtSignature
)
{
part
.
ThoughtSignature
=
block
.
Signature
}
else
if
!
allowDummyThought
{
// Claude 模型需要有效 signature;在缺失时降级为普通文本,并在上层禁用 thinking mode。
...
...
@@ -407,12 +409,12 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
},
}
// tool_use 的 signature 处理:
// - Gemini 模型:使用 dummy signature(跳过 thought_signature 校验)
// - Claude 模型:透传上游返回的真实 signature(Vertex/Google 需要完整签名链路)
if
allowDummyThought
{
part
.
ThoughtSignature
=
dummyThoughtSignature
}
else
if
block
.
Signature
!=
""
&&
block
.
Signature
!=
dummyThoughtSignature
{
// - Claude 模型(allowDummyThought=false):必须是上游返回的真实 signature(dummy 视为缺失)
// - Gemini 模型(allowDummyThought=true):优先透传真实 signature,缺失时使用 dummy signature
if
block
.
Signature
!=
""
&&
(
allowDummyThought
||
block
.
Signature
!=
dummyThoughtSignature
)
{
part
.
ThoughtSignature
=
block
.
Signature
}
else
if
allowDummyThought
{
part
.
ThoughtSignature
=
dummyThoughtSignature
}
parts
=
append
(
parts
,
part
)
...
...
backend/internal/pkg/antigravity/request_transformer_test.go
View file @
377bffe2
...
...
@@ -100,7 +100,7 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}, "signature": "sig_tool_abc"}
]`
t
.
Run
(
"Gemini
uses dummy
tool_use signature"
,
func
(
t
*
testing
.
T
)
{
t
.
Run
(
"Gemini
preserves provided
tool_use signature"
,
func
(
t
*
testing
.
T
)
{
toolIDToName
:=
make
(
map
[
string
]
string
)
parts
,
_
,
err
:=
buildParts
(
json
.
RawMessage
(
content
),
toolIDToName
,
true
)
if
err
!=
nil
{
...
...
@@ -109,6 +109,23 @@ func TestBuildParts_ToolUseSignatureHandling(t *testing.T) {
if
len
(
parts
)
!=
1
||
parts
[
0
]
.
FunctionCall
==
nil
{
t
.
Fatalf
(
"expected 1 functionCall part, got %+v"
,
parts
)
}
if
parts
[
0
]
.
ThoughtSignature
!=
"sig_tool_abc"
{
t
.
Fatalf
(
"expected preserved tool signature %q, got %q"
,
"sig_tool_abc"
,
parts
[
0
]
.
ThoughtSignature
)
}
})
t
.
Run
(
"Gemini falls back to dummy tool_use signature when missing"
,
func
(
t
*
testing
.
T
)
{
contentNoSig
:=
`[
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {"command": "ls"}}
]`
toolIDToName
:=
make
(
map
[
string
]
string
)
parts
,
_
,
err
:=
buildParts
(
json
.
RawMessage
(
contentNoSig
),
toolIDToName
,
true
)
if
err
!=
nil
{
t
.
Fatalf
(
"buildParts() error = %v"
,
err
)
}
if
len
(
parts
)
!=
1
||
parts
[
0
]
.
FunctionCall
==
nil
{
t
.
Fatalf
(
"expected 1 functionCall part, got %+v"
,
parts
)
}
if
parts
[
0
]
.
ThoughtSignature
!=
dummyThoughtSignature
{
t
.
Fatalf
(
"expected dummy tool signature %q, got %q"
,
dummyThoughtSignature
,
parts
[
0
]
.
ThoughtSignature
)
}
...
...
backend/internal/pkg/claude/constants.go
View file @
377bffe2
...
...
@@ -9,11 +9,26 @@ const (
BetaClaudeCode
=
"claude-code-20250219"
BetaInterleavedThinking
=
"interleaved-thinking-2025-05-14"
BetaFineGrainedToolStreaming
=
"fine-grained-tool-streaming-2025-05-14"
BetaTokenCounting
=
"token-counting-2024-11-01"
)
// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header
const
DefaultBetaHeader
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
+
","
+
BetaFineGrainedToolStreaming
// MessageBetaHeaderNoTools /v1/messages 在无工具时的 beta header
//
// NOTE: Claude Code OAuth credentials are scoped to Claude Code. When we "mimic"
// Claude Code for non-Claude-Code clients, we must include the claude-code beta
// even if the request doesn't use tools, otherwise upstream may reject the
// request as a non-Claude-Code API request.
const
MessageBetaHeaderNoTools
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
// MessageBetaHeaderWithTools /v1/messages 在有工具时的 beta header
const
MessageBetaHeaderWithTools
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
// CountTokensBetaHeader count_tokens 请求使用的 anthropic-beta header
const
CountTokensBetaHeader
=
BetaClaudeCode
+
","
+
BetaOAuth
+
","
+
BetaInterleavedThinking
+
","
+
BetaTokenCounting
// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta)
const
HaikuBetaHeader
=
BetaOAuth
+
","
+
BetaInterleavedThinking
...
...
@@ -25,15 +40,17 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
// DefaultHeaders 是 Claude Code 客户端默认请求头。
var
DefaultHeaders
=
map
[
string
]
string
{
"User-Agent"
:
"claude-cli/2.0.62 (external, cli)"
,
// Keep these in sync with recent Claude CLI traffic to reduce the chance
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
"User-Agent"
:
"claude-cli/2.1.22 (external, cli)"
,
"X-Stainless-Lang"
:
"js"
,
"X-Stainless-Package-Version"
:
"0.
52
.0"
,
"X-Stainless-Package-Version"
:
"0.
70
.0"
,
"X-Stainless-OS"
:
"Linux"
,
"X-Stainless-Arch"
:
"
x
64"
,
"X-Stainless-Arch"
:
"
arm
64"
,
"X-Stainless-Runtime"
:
"node"
,
"X-Stainless-Runtime-Version"
:
"v2
2
.1
4
.0"
,
"X-Stainless-Runtime-Version"
:
"v2
4
.1
3
.0"
,
"X-Stainless-Retry-Count"
:
"0"
,
"X-Stainless-Timeout"
:
"60"
,
"X-Stainless-Timeout"
:
"60
0
"
,
"X-App"
:
"cli"
,
"Anthropic-Dangerous-Direct-Browser-Access"
:
"true"
,
}
...
...
@@ -79,3 +96,39 @@ func DefaultModelIDs() []string {
// DefaultTestModel 测试时使用的默认模型
const
DefaultTestModel
=
"claude-sonnet-4-5-20250929"
// ModelIDOverrides Claude OAuth 请求需要的模型 ID 映射
var
ModelIDOverrides
=
map
[
string
]
string
{
"claude-sonnet-4-5"
:
"claude-sonnet-4-5-20250929"
,
"claude-opus-4-5"
:
"claude-opus-4-5-20251101"
,
"claude-haiku-4-5"
:
"claude-haiku-4-5-20251001"
,
}
// ModelIDReverseOverrides 用于将上游模型 ID 还原为短名
var
ModelIDReverseOverrides
=
map
[
string
]
string
{
"claude-sonnet-4-5-20250929"
:
"claude-sonnet-4-5"
,
"claude-opus-4-5-20251101"
:
"claude-opus-4-5"
,
"claude-haiku-4-5-20251001"
:
"claude-haiku-4-5"
,
}
// NormalizeModelID 根据 Claude OAuth 规则映射模型
func
NormalizeModelID
(
id
string
)
string
{
if
id
==
""
{
return
id
}
if
mapped
,
ok
:=
ModelIDOverrides
[
id
];
ok
{
return
mapped
}
return
id
}
// DenormalizeModelID 将上游模型 ID 转换为短名
func
DenormalizeModelID
(
id
string
)
string
{
if
id
==
""
{
return
id
}
if
mapped
,
ok
:=
ModelIDReverseOverrides
[
id
];
ok
{
return
mapped
}
return
id
}
backend/internal/pkg/response/response.go
View file @
377bffe2
...
...
@@ -2,6 +2,7 @@
package
response
import
(
"log"
"math"
"net/http"
...
...
@@ -74,6 +75,12 @@ func ErrorFrom(c *gin.Context, err error) bool {
}
statusCode
,
status
:=
infraerrors
.
ToHTTP
(
err
)
// Log internal errors with full details for debugging
if
statusCode
>=
500
&&
c
.
Request
!=
nil
{
log
.
Printf
(
"[ERROR] %s %s
\n
Error: %s"
,
c
.
Request
.
Method
,
c
.
Request
.
URL
.
Path
,
err
.
Error
())
}
ErrorWithDetails
(
c
,
statusCode
,
status
.
Message
,
status
.
Reason
,
status
.
Metadata
)
return
true
}
...
...
Prev
1
2
3
4
5
6
7
8
…
12
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