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
0170d19f
"backend/vscode:/vscode.git/clone" did not exist on "642842c29ee718f4926ead2619bd58ec2286c3aa"
Commit
0170d19f
authored
Feb 02, 2026
by
song
Browse files
merge upstream main
parent
7ade9baa
Changes
319
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/dto/types.go
View file @
0170d19f
...
...
@@ -11,7 +11,6 @@ type User struct {
ID
int64
`json:"id"`
Email
string
`json:"email"`
Username
string
`json:"username"`
Notes
string
`json:"notes"`
Role
string
`json:"role"`
Balance
float64
`json:"balance"`
Concurrency
int
`json:"concurrency"`
...
...
@@ -24,6 +23,14 @@ type User struct {
Subscriptions
[]
UserSubscription
`json:"subscriptions,omitempty"`
}
// AdminUser 是管理员接口使用的 user DTO(包含敏感/内部字段)。
// 注意:普通用户接口不得返回 notes 等管理员备注信息。
type
AdminUser
struct
{
User
Notes
string
`json:"notes"`
}
type
APIKey
struct
{
ID
int64
`json:"id"`
UserID
int64
`json:"user_id"`
...
...
@@ -65,6 +72,15 @@ type Group struct {
// 无效请求兜底分组
FallbackGroupIDOnInvalidRequest
*
int64
`json:"fallback_group_id_on_invalid_request"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
// AdminGroup 是管理员接口使用的 group DTO(包含敏感/内部字段)。
// 注意:普通用户接口不得返回 model_routing/account_count/account_groups 等内部信息。
type
AdminGroup
struct
{
Group
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting
map
[
string
][]
int64
`json:"model_routing"`
ModelRoutingEnabled
bool
`json:"model_routing_enabled"`
...
...
@@ -72,9 +88,6 @@ type Group struct {
// MCP XML 协议注入(仅 antigravity 平台使用)
MCPXMLInject
bool
`json:"mcp_xml_inject"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
AccountCount
int64
`json:"account_count,omitempty"`
}
...
...
@@ -125,6 +138,15 @@ type Account struct {
MaxSessions
*
int
`json:"max_sessions,omitempty"`
SessionIdleTimeoutMin
*
int
`json:"session_idle_timeout_minutes,omitempty"`
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 从 extra 字段提取,方便前端显示和编辑
EnableTLSFingerprint
*
bool
`json:"enable_tls_fingerprint,omitempty"`
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
// 从 extra 字段提取,方便前端显示和编辑
EnableSessionIDMasking
*
bool
`json:"session_id_masking_enabled,omitempty"`
Proxy
*
Proxy
`json:"proxy,omitempty"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
...
...
@@ -184,16 +206,28 @@ type RedeemCode struct {
Status
string
`json:"status"`
UsedBy
*
int64
`json:"used_by"`
UsedAt
*
time
.
Time
`json:"used_at"`
Notes
string
`json:"notes"`
CreatedAt
time
.
Time
`json:"created_at"`
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"`
}
// AdminRedeemCode 是管理员接口使用的 redeem code DTO(包含 notes 等字段)。
// 注意:普通用户接口不得返回 notes 等内部信息。
type
AdminRedeemCode
struct
{
RedeemCode
Notes
string
`json:"notes"`
}
// UsageLog 是普通用户接口使用的 usage log DTO(不包含管理员字段)。
type
UsageLog
struct
{
ID
int64
`json:"id"`
UserID
int64
`json:"user_id"`
...
...
@@ -213,14 +247,13 @@ type UsageLog struct {
CacheCreation5mTokens
int
`json:"cache_creation_5m_tokens"`
CacheCreation1hTokens
int
`json:"cache_creation_1h_tokens"`
InputCost
float64
`json:"input_cost"`
OutputCost
float64
`json:"output_cost"`
CacheCreationCost
float64
`json:"cache_creation_cost"`
CacheReadCost
float64
`json:"cache_read_cost"`
TotalCost
float64
`json:"total_cost"`
ActualCost
float64
`json:"actual_cost"`
RateMultiplier
float64
`json:"rate_multiplier"`
AccountRateMultiplier
*
float64
`json:"account_rate_multiplier"`
InputCost
float64
`json:"input_cost"`
OutputCost
float64
`json:"output_cost"`
CacheCreationCost
float64
`json:"cache_creation_cost"`
CacheReadCost
float64
`json:"cache_read_cost"`
TotalCost
float64
`json:"total_cost"`
ActualCost
float64
`json:"actual_cost"`
RateMultiplier
float64
`json:"rate_multiplier"`
BillingType
int8
`json:"billing_type"`
Stream
bool
`json:"stream"`
...
...
@@ -234,18 +267,55 @@ type UsageLog struct {
// User-Agent
UserAgent
*
string
`json:"user_agent"`
// IP 地址(仅管理员可见)
IPAddress
*
string
`json:"ip_address,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
User
*
User
`json:"user,omitempty"`
APIKey
*
APIKey
`json:"api_key,omitempty"`
Account
*
AccountSummary
`json:"account,omitempty"`
// Use minimal AccountSummary to prevent data leakage
Group
*
Group
`json:"group,omitempty"`
Subscription
*
UserSubscription
`json:"subscription,omitempty"`
}
// AdminUsageLog 是管理员接口使用的 usage log DTO(包含管理员字段)。
type
AdminUsageLog
struct
{
UsageLog
// AccountRateMultiplier 账号计费倍率快照(nil 表示按 1.0 处理)
AccountRateMultiplier
*
float64
`json:"account_rate_multiplier"`
// IPAddress 用户请求 IP(仅管理员可见)
IPAddress
*
string
`json:"ip_address,omitempty"`
// Account 最小账号信息(避免泄露敏感字段)
Account
*
AccountSummary
`json:"account,omitempty"`
}
type
UsageCleanupFilters
struct
{
StartTime
time
.
Time
`json:"start_time"`
EndTime
time
.
Time
`json:"end_time"`
UserID
*
int64
`json:"user_id,omitempty"`
APIKeyID
*
int64
`json:"api_key_id,omitempty"`
AccountID
*
int64
`json:"account_id,omitempty"`
GroupID
*
int64
`json:"group_id,omitempty"`
Model
*
string
`json:"model,omitempty"`
Stream
*
bool
`json:"stream,omitempty"`
BillingType
*
int8
`json:"billing_type,omitempty"`
}
type
UsageCleanupTask
struct
{
ID
int64
`json:"id"`
Status
string
`json:"status"`
Filters
UsageCleanupFilters
`json:"filters"`
CreatedBy
int64
`json:"created_by"`
DeletedRows
int64
`json:"deleted_rows"`
ErrorMessage
*
string
`json:"error_message,omitempty"`
CanceledBy
*
int64
`json:"canceled_by,omitempty"`
CanceledAt
*
time
.
Time
`json:"canceled_at,omitempty"`
StartedAt
*
time
.
Time
`json:"started_at,omitempty"`
FinishedAt
*
time
.
Time
`json:"finished_at,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
// AccountSummary is a minimal account info for usage log display.
// It intentionally excludes sensitive fields like Credentials, Proxy, etc.
type
AccountSummary
struct
{
...
...
@@ -277,23 +347,30 @@ type UserSubscription struct {
WeeklyUsageUSD
float64
`json:"weekly_usage_usd"`
MonthlyUsageUSD
float64
`json:"monthly_usage_usd"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
User
*
User
`json:"user,omitempty"`
Group
*
Group
`json:"group,omitempty"`
}
// AdminUserSubscription 是管理员接口使用的订阅 DTO(包含分配信息/备注等字段)。
// 注意:普通用户接口不得返回 assigned_by/assigned_at/notes/assigned_by_user 等管理员字段。
type
AdminUserSubscription
struct
{
UserSubscription
AssignedBy
*
int64
`json:"assigned_by"`
AssignedAt
time
.
Time
`json:"assigned_at"`
Notes
string
`json:"notes"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
User
*
User
`json:"user,omitempty"`
Group
*
Group
`json:"group,omitempty"`
AssignedByUser
*
User
`json:"assigned_by_user,omitempty"`
AssignedByUser
*
User
`json:"assigned_by_user,omitempty"`
}
type
BulkAssignResult
struct
{
SuccessCount
int
`json:"success_count"`
FailedCount
int
`json:"failed_count"`
Subscriptions
[]
UserSubscription
`json:"subscriptions"`
Errors
[]
string
`json:"errors"`
SuccessCount
int
`json:"success_count"`
FailedCount
int
`json:"failed_count"`
Subscriptions
[]
Admin
UserSubscription
`json:"subscriptions"`
Errors
[]
string
`json:"errors"`
}
// PromoCode 注册优惠码
...
...
backend/internal/handler/gateway_handler.go
View file @
0170d19f
...
...
@@ -31,6 +31,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
,
...
...
@@ -210,17 +213,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
// 检查预热请求拦截(在账号选择后、转发前检查)
if
account
.
IsInterceptWarmupEnabled
()
&&
isWarmupRequest
(
body
)
{
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
if
reqStream
{
sendMockWarmupStream
(
c
,
reqModel
)
}
else
{
sendMockWarmupResponse
(
c
,
reqModel
)
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if
account
.
IsInterceptWarmupEnabled
()
{
interceptType
:=
detectInterceptType
(
body
)
if
interceptType
!=
InterceptTypeNone
{
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
if
reqStream
{
sendMockInterceptStream
(
c
,
reqModel
,
interceptType
)
}
else
{
sendMockInterceptResponse
(
c
,
reqModel
,
interceptType
)
}
return
}
return
}
// 3. 获取账号并发槽位
...
...
@@ -359,17 +365,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
// 检查预热请求拦截(在账号选择后、转发前检查)
if
account
.
IsInterceptWarmupEnabled
()
&&
isWarmupRequest
(
body
)
{
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
if
reqStream
{
sendMockWarmupStream
(
c
,
reqModel
)
}
else
{
sendMockWarmupResponse
(
c
,
reqModel
)
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if
account
.
IsInterceptWarmupEnabled
()
{
interceptType
:=
detectInterceptType
(
body
)
if
interceptType
!=
InterceptTypeNone
{
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
if
reqStream
{
sendMockInterceptStream
(
c
,
reqModel
,
interceptType
)
}
else
{
sendMockInterceptResponse
(
c
,
reqModel
,
interceptType
)
}
return
}
return
}
// 3. 获取账号并发槽位
...
...
@@ -588,7 +597,7 @@ func cloneAPIKeyWithGroup(apiKey *service.APIKey, group *service.Group) *service
return
&
cloned
}
// 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
)
...
...
@@ -603,7 +612,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
{
...
...
@@ -612,28 +654,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 计算订阅剩余可用额度
...
...
@@ -835,17 +895,30 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
}
}
// isWarmupRequest 检测是否为预热请求(标题生成、Warmup等)
func
isWarmupRequest
(
body
[]
byte
)
bool
{
// 快速检查:如果body不包含关键字,直接返回false
// InterceptType 表示请求拦截类型
type
InterceptType
int
const
(
InterceptTypeNone
InterceptType
=
iota
InterceptTypeWarmup
// 预热请求(返回 "New Conversation")
InterceptTypeSuggestionMode
// SUGGESTION MODE(返回空字符串)
)
// detectInterceptType 检测请求是否需要拦截,返回拦截类型
func
detectInterceptType
(
body
[]
byte
)
InterceptType
{
// 快速检查:如果不包含任何关键字,直接返回
bodyStr
:=
string
(
body
)
if
!
strings
.
Contains
(
bodyStr
,
"title"
)
&&
!
strings
.
Contains
(
bodyStr
,
"Warmup"
)
{
return
false
hasSuggestionMode
:=
strings
.
Contains
(
bodyStr
,
"[SUGGESTION MODE:"
)
hasWarmupKeyword
:=
strings
.
Contains
(
bodyStr
,
"title"
)
||
strings
.
Contains
(
bodyStr
,
"Warmup"
)
if
!
hasSuggestionMode
&&
!
hasWarmupKeyword
{
return
InterceptTypeNone
}
// 解析
完整
请求
// 解析请求
(只解析一次)
var
req
struct
{
Messages
[]
struct
{
Role
string
`json:"role"`
Content
[]
struct
{
Type
string
`json:"type"`
Text
string
`json:"text"`
...
...
@@ -856,43 +929,71 @@ func isWarmupRequest(body []byte) bool {
}
`json:"system"`
}
if
err
:=
json
.
Unmarshal
(
body
,
&
req
);
err
!=
nil
{
return
fals
e
return
InterceptTypeNon
e
}
// 检查 messages 中的标题提示模式
for
_
,
msg
:=
range
req
.
Messages
{
for
_
,
content
:=
range
msg
.
Content
{
if
content
.
Type
==
"text"
{
if
strings
.
Contains
(
content
.
Text
,
"Please write a 5-10 word title for the following conversation:"
)
||
content
.
Text
==
"Warmup"
{
return
true
}
}
// 检查 SUGGESTION MODE(最后一条 user 消息)
if
hasSuggestionMode
&&
len
(
req
.
Messages
)
>
0
{
lastMsg
:=
req
.
Messages
[
len
(
req
.
Messages
)
-
1
]
if
lastMsg
.
Role
==
"user"
&&
len
(
lastMsg
.
Content
)
>
0
&&
lastMsg
.
Content
[
0
]
.
Type
==
"text"
&&
strings
.
HasPrefix
(
lastMsg
.
Content
[
0
]
.
Text
,
"[SUGGESTION MODE:"
)
{
return
InterceptTypeSuggestionMode
}
}
// 检查 system 中的标题提取模式
for
_
,
system
:=
range
req
.
System
{
if
strings
.
Contains
(
system
.
Text
,
"nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title"
)
{
return
true
// 检查 Warmup 请求
if
hasWarmupKeyword
{
// 检查 messages 中的标题提示模式
for
_
,
msg
:=
range
req
.
Messages
{
for
_
,
content
:=
range
msg
.
Content
{
if
content
.
Type
==
"text"
{
if
strings
.
Contains
(
content
.
Text
,
"Please write a 5-10 word title for the following conversation:"
)
||
content
.
Text
==
"Warmup"
{
return
InterceptTypeWarmup
}
}
}
}
// 检查 system 中的标题提取模式
for
_
,
sys
:=
range
req
.
System
{
if
strings
.
Contains
(
sys
.
Text
,
"nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title"
)
{
return
InterceptTypeWarmup
}
}
}
return
fals
e
return
InterceptTypeNon
e
}
// sendMock
Warmup
Stream 发送流式 mock 响应(用于
预热
请求拦截)
func
sendMock
Warmup
Stream
(
c
*
gin
.
Context
,
model
string
)
{
// sendMock
Intercept
Stream 发送流式 mock 响应(用于请求拦截)
func
sendMock
Intercept
Stream
(
c
*
gin
.
Context
,
model
string
,
interceptType
InterceptType
)
{
c
.
Header
(
"Content-Type"
,
"text/event-stream"
)
c
.
Header
(
"Cache-Control"
,
"no-cache"
)
c
.
Header
(
"Connection"
,
"keep-alive"
)
c
.
Header
(
"X-Accel-Buffering"
,
"no"
)
// 根据拦截类型决定响应内容
var
msgID
string
var
outputTokens
int
var
textDeltas
[]
string
switch
interceptType
{
case
InterceptTypeSuggestionMode
:
msgID
=
"msg_mock_suggestion"
outputTokens
=
1
textDeltas
=
[]
string
{
""
}
// 空内容
default
:
// InterceptTypeWarmup
msgID
=
"msg_mock_warmup"
outputTokens
=
2
textDeltas
=
[]
string
{
"New"
,
" Conversation"
}
}
// Build message_start event with proper JSON marshaling
messageStart
:=
map
[
string
]
any
{
"type"
:
"message_start"
,
"message"
:
map
[
string
]
any
{
"id"
:
"
msg
_mock_warmup"
,
"id"
:
msg
ID
,
"type"
:
"message"
,
"role"
:
"assistant"
,
"model"
:
model
,
...
...
@@ -907,16 +1008,46 @@ func sendMockWarmupStream(c *gin.Context, model string) {
}
messageStartJSON
,
_
:=
json
.
Marshal
(
messageStart
)
// Build events
events
:=
[]
string
{
`event: message_start`
+
"
\n
"
+
`data: `
+
string
(
messageStartJSON
),
`event: content_block_start`
+
"
\n
"
+
`data: {"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"}`
,
`event: content_block_delta`
+
"
\n
"
+
`data: {"delta":{"text":"New","type":"text_delta"},"index":0,"type":"content_block_delta"}`
,
`event: content_block_delta`
+
"
\n
"
+
`data: {"delta":{"text":" Conversation","type":"text_delta"},"index":0,"type":"content_block_delta"}`
,
`event: content_block_stop`
+
"
\n
"
+
`data: {"index":0,"type":"content_block_stop"}`
,
`event: message_delta`
+
"
\n
"
+
`data: {"delta":{"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"input_tokens":10,"output_tokens":2}}`
,
`event: message_stop`
+
"
\n
"
+
`data: {"type":"message_stop"}`
,
}
// 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
)
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
)
events
=
append
(
events
,
`event: content_block_stop`
+
"
\n
"
+
`data: {"index":0,"type":"content_block_stop"}`
,
`event: message_delta`
+
"
\n
"
+
`data: `
+
string
(
messageDeltaJSON
),
`event: message_stop`
+
"
\n
"
+
`data: {"type":"message_stop"}`
,
)
for
_
,
event
:=
range
events
{
_
,
_
=
c
.
Writer
.
WriteString
(
event
+
"
\n\n
"
)
c
.
Writer
.
Flush
()
...
...
@@ -924,18 +1055,32 @@ func sendMockWarmupStream(c *gin.Context, model string) {
}
}
// sendMockWarmupResponse 发送非流式 mock 响应(用于预热请求拦截)
func
sendMockWarmupResponse
(
c
*
gin
.
Context
,
model
string
)
{
// sendMockInterceptResponse 发送非流式 mock 响应(用于请求拦截)
func
sendMockInterceptResponse
(
c
*
gin
.
Context
,
model
string
,
interceptType
InterceptType
)
{
var
msgID
,
text
string
var
outputTokens
int
switch
interceptType
{
case
InterceptTypeSuggestionMode
:
msgID
=
"msg_mock_suggestion"
text
=
""
outputTokens
=
1
default
:
// InterceptTypeWarmup
msgID
=
"msg_mock_warmup"
text
=
"New Conversation"
outputTokens
=
2
}
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"id"
:
"
msg
_mock_warmup"
,
"id"
:
msg
ID
,
"type"
:
"message"
,
"role"
:
"assistant"
,
"model"
:
model
,
"content"
:
[]
gin
.
H
{{
"type"
:
"text"
,
"text"
:
"New Conversation"
}},
"content"
:
[]
gin
.
H
{{
"type"
:
"text"
,
"text"
:
text
}},
"stop_reason"
:
"end_turn"
,
"usage"
:
gin
.
H
{
"input_tokens"
:
10
,
"output_tokens"
:
2
,
"output_tokens"
:
outputTokens
,
},
})
}
...
...
backend/internal/handler/gemini_cli_session_test.go
0 → 100644
View file @
0170d19f
//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 @
0170d19f
package
handler
import
(
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log"
"net/http"
"regexp"
"strings"
"time"
...
...
@@ -20,6 +24,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
)
{
...
...
@@ -215,12 +230,26 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
}
// 3) select account (sticky session based on request body)
parsedReq
,
_
:=
service
.
ParseGatewayRequest
(
body
)
sessionHash
:=
h
.
gatewayService
.
GenerateSessionHash
(
parsedReq
)
// 优先使用 Gemini CLI 的会话标识(privileged-user-id + tmp 目录哈希)
sessionHash
:=
extractGeminiCLISessionHash
(
c
,
body
)
if
sessionHash
==
""
{
// Fallback: 使用通用的会话哈希生成逻辑(适用于其他客户端)
parsedReq
,
_
:=
service
.
ParseGatewayRequest
(
body
)
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
{})
...
...
@@ -239,6 +268,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
{
...
...
@@ -438,3 +485,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 @
0170d19f
...
...
@@ -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,10 +34,12 @@ type Handlers struct {
Usage
*
UsageHandler
Redeem
*
RedeemHandler
Subscription
*
SubscriptionHandler
Announcement
*
AnnouncementHandler
Admin
*
AdminHandlers
Gateway
*
GatewayHandler
OpenAIGateway
*
OpenAIGatewayHandler
Setting
*
SettingHandler
Totp
*
TotpHandler
}
// BuildInfo contains build-time information
...
...
backend/internal/handler/openai_gateway_handler.go
View file @
0170d19f
...
...
@@ -192,8 +192,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
}
// Generate session hash (
from
header f
or OpenAI
)
sessionHash
:=
h
.
gatewayService
.
GenerateSessionHash
(
c
)
// Generate session hash (header f
irst; fallback to prompt_cache_key
)
sessionHash
:=
h
.
gatewayService
.
GenerateSessionHash
(
c
,
reqBody
)
maxAccountSwitches
:=
h
.
maxAccountSwitches
switchCount
:=
0
...
...
backend/internal/handler/ops_error_logger.go
View file @
0170d19f
...
...
@@ -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 @
0170d19f
...
...
@@ -32,18 +32,24 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
}
response
.
Success
(
c
,
dto
.
PublicSettings
{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
TurnstileEnabled
:
settings
.
TurnstileEnabled
,
TurnstileSiteKey
:
settings
.
TurnstileSiteKey
,
SiteName
:
settings
.
SiteName
,
SiteLogo
:
settings
.
SiteLogo
,
SiteSubtitle
:
settings
.
SiteSubtitle
,
APIBaseURL
:
settings
.
APIBaseURL
,
ContactInfo
:
settings
.
ContactInfo
,
DocURL
:
settings
.
DocURL
,
HomeContent
:
settings
.
HomeContent
,
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
Version
:
h
.
version
,
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
settings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
settings
.
PasswordResetEnabled
,
TotpEnabled
:
settings
.
TotpEnabled
,
TurnstileEnabled
:
settings
.
TurnstileEnabled
,
TurnstileSiteKey
:
settings
.
TurnstileSiteKey
,
SiteName
:
settings
.
SiteName
,
SiteLogo
:
settings
.
SiteLogo
,
SiteSubtitle
:
settings
.
SiteSubtitle
,
APIBaseURL
:
settings
.
APIBaseURL
,
ContactInfo
:
settings
.
ContactInfo
,
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 @
0170d19f
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/user_handler.go
View file @
0170d19f
...
...
@@ -47,9 +47,6 @@ func (h *UserHandler) GetProfile(c *gin.Context) {
return
}
// 清空notes字段,普通用户不应看到备注
userData
.
Notes
=
""
response
.
Success
(
c
,
dto
.
UserFromService
(
userData
))
}
...
...
@@ -105,8 +102,5 @@ func (h *UserHandler) UpdateProfile(c *gin.Context) {
return
}
// 清空notes字段,普通用户不应看到备注
updatedUser
.
Notes
=
""
response
.
Success
(
c
,
dto
.
UserFromService
(
updatedUser
))
}
backend/internal/handler/wire.go
View file @
0170d19f
...
...
@@ -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,10 +68,12 @@ func ProvideHandlers(
usageHandler
*
UsageHandler
,
redeemHandler
*
RedeemHandler
,
subscriptionHandler
*
SubscriptionHandler
,
announcementHandler
*
AnnouncementHandler
,
adminHandlers
*
AdminHandlers
,
gatewayHandler
*
GatewayHandler
,
openaiGatewayHandler
*
OpenAIGatewayHandler
,
settingHandler
*
SettingHandler
,
totpHandler
*
TotpHandler
,
)
*
Handlers
{
return
&
Handlers
{
Auth
:
authHandler
,
...
...
@@ -78,10 +82,12 @@ func ProvideHandlers(
Usage
:
usageHandler
,
Redeem
:
redeemHandler
,
Subscription
:
subscriptionHandler
,
Announcement
:
announcementHandler
,
Admin
:
adminHandlers
,
Gateway
:
gatewayHandler
,
OpenAIGateway
:
openaiGatewayHandler
,
Setting
:
settingHandler
,
Totp
:
totpHandler
,
}
}
...
...
@@ -94,8 +100,10 @@ var ProviderSet = wire.NewSet(
NewUsageHandler
,
NewRedeemHandler
,
NewSubscriptionHandler
,
NewAnnouncementHandler
,
NewGatewayHandler
,
NewOpenAIGatewayHandler
,
NewTotpHandler
,
ProvideSettingHandler
,
// Admin handlers
...
...
@@ -103,6 +111,7 @@ var ProviderSet = wire.NewSet(
admin
.
NewUserHandler
,
admin
.
NewGroupHandler
,
admin
.
NewAccountHandler
,
admin
.
NewAnnouncementHandler
,
admin
.
NewOAuthHandler
,
admin
.
NewOpenAIOAuthHandler
,
admin
.
NewGeminiOAuthHandler
,
...
...
backend/internal/middleware/rate_limiter_integration_test.go
View file @
0170d19f
...
...
@@ -7,6 +7,9 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
...
...
@@ -88,6 +91,7 @@ func performRequest(router *gin.Engine) *httptest.ResponseRecorder {
func
startRedis
(
t
*
testing
.
T
,
ctx
context
.
Context
)
*
redis
.
Client
{
t
.
Helper
()
ensureDockerAvailable
(
t
)
redisContainer
,
err
:=
tcredis
.
Run
(
ctx
,
redisImageTag
)
require
.
NoError
(
t
,
err
)
...
...
@@ -112,3 +116,43 @@ func startRedis(t *testing.T, ctx context.Context) *redis.Client {
return
rdb
}
func
ensureDockerAvailable
(
t
*
testing
.
T
)
{
t
.
Helper
()
if
dockerAvailable
()
{
return
}
t
.
Skip
(
"Docker 未启用,跳过依赖 testcontainers 的集成测试"
)
}
func
dockerAvailable
()
bool
{
if
os
.
Getenv
(
"DOCKER_HOST"
)
!=
""
{
return
true
}
socketCandidates
:=
[]
string
{
"/var/run/docker.sock"
,
filepath
.
Join
(
os
.
Getenv
(
"XDG_RUNTIME_DIR"
),
"docker.sock"
),
filepath
.
Join
(
userHomeDir
(),
".docker"
,
"run"
,
"docker.sock"
),
filepath
.
Join
(
userHomeDir
(),
".docker"
,
"desktop"
,
"docker.sock"
),
filepath
.
Join
(
"/run/user"
,
strconv
.
Itoa
(
os
.
Getuid
()),
"docker.sock"
),
}
for
_
,
socket
:=
range
socketCandidates
{
if
socket
==
""
{
continue
}
if
_
,
err
:=
os
.
Stat
(
socket
);
err
==
nil
{
return
true
}
}
return
false
}
func
userHomeDir
()
string
{
home
,
err
:=
os
.
UserHomeDir
()
if
err
!=
nil
{
return
""
}
return
home
}
backend/internal/pkg/antigravity/oauth.go
View file @
0170d19f
...
...
@@ -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 @
0170d19f
...
...
@@ -369,8 +369,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。
...
...
@@ -409,12 +411,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 @
0170d19f
...
...
@@ -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/antigravity/response_transformer.go
View file @
0170d19f
...
...
@@ -20,6 +20,15 @@ func TransformGeminiToClaude(geminiResp []byte, originalModel string) ([]byte, *
v1Resp
.
Response
=
directResp
v1Resp
.
ResponseID
=
directResp
.
ResponseID
v1Resp
.
ModelVersion
=
directResp
.
ModelVersion
}
else
if
len
(
v1Resp
.
Response
.
Candidates
)
==
0
{
// 第一次解析成功但 candidates 为空,说明是直接的 GeminiResponse 格式
var
directResp
GeminiResponse
if
err2
:=
json
.
Unmarshal
(
geminiResp
,
&
directResp
);
err2
!=
nil
{
return
nil
,
nil
,
fmt
.
Errorf
(
"parse gemini response as direct: %w"
,
err2
)
}
v1Resp
.
Response
=
directResp
v1Resp
.
ResponseID
=
directResp
.
ResponseID
v1Resp
.
ModelVersion
=
directResp
.
ModelVersion
}
// 使用处理器转换
...
...
@@ -174,16 +183,20 @@ func (p *NonStreamingProcessor) processPart(part *GeminiPart) {
p
.
trailingSignature
=
""
}
p
.
textBuilder
+=
part
.
Text
// 非空 text 带签名 - 立即刷新并输出空 thinking 块
// 非空 text 带签名 - 特殊处理:先输出 text,再输出空 thinking 块
if
signature
!=
""
{
p
.
flushText
()
p
.
contentBlocks
=
append
(
p
.
contentBlocks
,
ClaudeContentItem
{
Type
:
"text"
,
Text
:
part
.
Text
,
})
p
.
contentBlocks
=
append
(
p
.
contentBlocks
,
ClaudeContentItem
{
Type
:
"thinking"
,
Thinking
:
""
,
Signature
:
signature
,
})
}
else
{
// 普通 text (无签名) - 累积到 builder
p
.
textBuilder
+=
part
.
Text
}
}
}
...
...
backend/internal/pkg/gemini/models.go
View file @
0170d19f
...
...
@@ -16,14 +16,11 @@ type ModelsListResponse struct {
func
DefaultModels
()
[]
Model
{
methods
:=
[]
string
{
"generateContent"
,
"streamGenerateContent"
}
return
[]
Model
{
{
Name
:
"models/gemini-3-pro-preview"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-3-flash-preview"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-pro"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.0-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-1.5-pro"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-1.5-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-1.5-flash-8b"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-pro"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-3-flash-preview"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-3-pro-preview"
,
SupportedGenerationMethods
:
methods
},
}
}
...
...
backend/internal/pkg/geminicli/models.go
View file @
0170d19f
...
...
@@ -12,10 +12,10 @@ type Model struct {
// DefaultModels is the curated Gemini model list used by the admin UI "test account" flow.
var
DefaultModels
=
[]
Model
{
{
ID
:
"gemini-2.0-flash"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.0 Flash"
,
CreatedAt
:
""
},
{
ID
:
"gemini-2.5-pro"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.5 Pro"
,
CreatedAt
:
""
},
{
ID
:
"gemini-2.5-flash"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.5 Flash"
,
CreatedAt
:
""
},
{
ID
:
"gemini-
3
-pro
-preview
"
,
Type
:
"model"
,
DisplayName
:
"Gemini
3
Pro
Preview
"
,
CreatedAt
:
""
},
{
ID
:
"gemini-
2.5
-pro"
,
Type
:
"model"
,
DisplayName
:
"Gemini
2.5
Pro"
,
CreatedAt
:
""
},
{
ID
:
"gemini-3-flash-preview"
,
Type
:
"model"
,
DisplayName
:
"Gemini 3 Flash Preview"
,
CreatedAt
:
""
},
{
ID
:
"gemini-3-pro-preview"
,
Type
:
"model"
,
DisplayName
:
"Gemini 3 Pro Preview"
,
CreatedAt
:
""
},
}
// DefaultTestModel is the default model to preselect in test flows.
...
...
backend/internal/pkg/oauth/oauth.go
View file @
0170d19f
...
...
@@ -13,20 +13,26 @@ import (
"time"
)
// Claude OAuth Constants
(from CRS project)
// Claude OAuth Constants
const
(
// OAuth Client ID for Claude
ClientID
=
"9d1c250a-e61b-44d9-88ed-5944d1962f5e"
// OAuth endpoints
AuthorizeURL
=
"https://claude.ai/oauth/authorize"
TokenURL
=
"https://console.anthropic.com/v1/oauth/token"
RedirectURI
=
"https://console.anthropic.com/oauth/code/callback"
// Scopes
ScopeProfile
=
"user:profile"
TokenURL
=
"https://platform.claude.com/v1/oauth/token"
RedirectURI
=
"https://platform.claude.com/oauth/code/callback"
// Scopes - Browser URL (includes org:create_api_key for user authorization)
ScopeOAuth
=
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers"
// Scopes - Internal API call (org:create_api_key not supported in API)
ScopeAPI
=
"user:profile user:inference user:sessions:claude_code user:mcp_servers"
// Scopes - Setup token (inference only)
ScopeInference
=
"user:inference"
// Code Verifier character set (RFC 7636 compliant)
codeVerifierCharset
=
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
// Session TTL
SessionTTL
=
30
*
time
.
Minute
)
...
...
@@ -53,7 +59,6 @@ func NewSessionStore() *SessionStore {
sessions
:
make
(
map
[
string
]
*
OAuthSession
),
stopCh
:
make
(
chan
struct
{}),
}
// Start cleanup goroutine
go
store
.
cleanup
()
return
store
}
...
...
@@ -78,7 +83,6 @@ func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
if
!
ok
{
return
nil
,
false
}
// Check if expired
if
time
.
Since
(
session
.
CreatedAt
)
>
SessionTTL
{
return
nil
,
false
}
...
...
@@ -122,13 +126,13 @@ func GenerateRandomBytes(n int) ([]byte, error) {
return
b
,
nil
}
// GenerateState generates a random state string for OAuth
// GenerateState generates a random state string for OAuth
(base64url encoded)
func
GenerateState
()
(
string
,
error
)
{
bytes
,
err
:=
GenerateRandomBytes
(
32
)
if
err
!=
nil
{
return
""
,
err
}
return
hex
.
EncodeToString
(
bytes
),
nil
return
base64URLEncode
(
bytes
),
nil
}
// GenerateSessionID generates a unique session ID
...
...
@@ -140,13 +144,30 @@ func GenerateSessionID() (string, error) {
return
hex
.
EncodeToString
(
bytes
),
nil
}
// GenerateCodeVerifier generates a PKCE code verifier
(32 bytes -> base64url)
// GenerateCodeVerifier generates a PKCE code verifier
using character set method
func
GenerateCodeVerifier
()
(
string
,
error
)
{
bytes
,
err
:=
GenerateRandomBytes
(
32
)
if
err
!=
nil
{
return
""
,
err
const
targetLen
=
32
charsetLen
:=
len
(
codeVerifierCharset
)
limit
:=
256
-
(
256
%
charsetLen
)
result
:=
make
([]
byte
,
0
,
targetLen
)
randBuf
:=
make
([]
byte
,
targetLen
*
2
)
for
len
(
result
)
<
targetLen
{
if
_
,
err
:=
rand
.
Read
(
randBuf
);
err
!=
nil
{
return
""
,
err
}
for
_
,
b
:=
range
randBuf
{
if
int
(
b
)
<
limit
{
result
=
append
(
result
,
codeVerifierCharset
[
int
(
b
)
%
charsetLen
])
if
len
(
result
)
>=
targetLen
{
break
}
}
}
}
return
base64URLEncode
(
bytes
),
nil
return
base64URLEncode
(
result
),
nil
}
// GenerateCodeChallenge generates a PKCE code challenge using S256 method
...
...
@@ -158,42 +179,31 @@ func GenerateCodeChallenge(verifier string) string {
// base64URLEncode encodes bytes to base64url without padding
func
base64URLEncode
(
data
[]
byte
)
string
{
encoded
:=
base64
.
URLEncoding
.
EncodeToString
(
data
)
// Remove padding
return
strings
.
TrimRight
(
encoded
,
"="
)
}
// BuildAuthorizationURL builds the OAuth authorization URL
// BuildAuthorizationURL builds the OAuth authorization URL
with correct parameter order
func
BuildAuthorizationURL
(
state
,
codeChallenge
,
scope
string
)
string
{
params
:=
url
.
Values
{}
params
.
Set
(
"response_type"
,
"code"
)
params
.
Set
(
"client_id"
,
ClientID
)
params
.
Set
(
"redirect_uri"
,
RedirectURI
)
params
.
Set
(
"scope"
,
scope
)
params
.
Set
(
"state"
,
state
)
params
.
Set
(
"code_challenge"
,
codeChallenge
)
params
.
Set
(
"code_challenge_method"
,
"S256"
)
return
fmt
.
Sprintf
(
"%s?%s"
,
AuthorizeURL
,
params
.
Encode
())
}
encodedRedirectURI
:=
url
.
QueryEscape
(
RedirectURI
)
encodedScope
:=
strings
.
ReplaceAll
(
url
.
QueryEscape
(
scope
),
"%20"
,
"+"
)
// TokenRequest represents the token exchange request body
type
TokenRequest
struct
{
GrantType
string
`json:"grant_type"`
ClientID
string
`json:"client_id"`
Code
string
`json:"code"`
RedirectURI
string
`json:"redirect_uri"`
CodeVerifier
string
`json:"code_verifier"`
State
string
`json:"state"`
return
fmt
.
Sprintf
(
"%s?code=true&client_id=%s&response_type=code&redirect_uri=%s&scope=%s&code_challenge=%s&code_challenge_method=S256&state=%s"
,
AuthorizeURL
,
ClientID
,
encodedRedirectURI
,
encodedScope
,
codeChallenge
,
state
,
)
}
// TokenResponse represents the token response from OAuth provider
type
TokenResponse
struct
{
AccessToken
string
`json:"access_token"`
TokenType
string
`json:"token_type"`
ExpiresIn
int64
`json:"expires_in"`
RefreshToken
string
`json:"refresh_token,omitempty"`
Scope
string
`json:"scope,omitempty"`
// Organization and Account info from OAuth response
AccessToken
string
`json:"access_token"`
TokenType
string
`json:"token_type"`
ExpiresIn
int64
`json:"expires_in"`
RefreshToken
string
`json:"refresh_token,omitempty"`
Scope
string
`json:"scope,omitempty"`
Organization
*
OrgInfo
`json:"organization,omitempty"`
Account
*
AccountInfo
`json:"account,omitempty"`
}
...
...
@@ -205,33 +215,6 @@ type OrgInfo struct {
// AccountInfo represents account info from OAuth response
type
AccountInfo
struct
{
UUID
string
`json:"uuid"`
}
// RefreshTokenRequest represents the refresh token request
type
RefreshTokenRequest
struct
{
GrantType
string
`json:"grant_type"`
RefreshToken
string
`json:"refresh_token"`
ClientID
string
`json:"client_id"`
}
// BuildTokenRequest creates a token exchange request
func
BuildTokenRequest
(
code
,
codeVerifier
,
state
string
)
*
TokenRequest
{
return
&
TokenRequest
{
GrantType
:
"authorization_code"
,
ClientID
:
ClientID
,
Code
:
code
,
RedirectURI
:
RedirectURI
,
CodeVerifier
:
codeVerifier
,
State
:
state
,
}
}
// BuildRefreshTokenRequest creates a refresh token request
func
BuildRefreshTokenRequest
(
refreshToken
string
)
*
RefreshTokenRequest
{
return
&
RefreshTokenRequest
{
GrantType
:
"refresh_token"
,
RefreshToken
:
refreshToken
,
ClientID
:
ClientID
,
}
UUID
string
`json:"uuid"`
EmailAddress
string
`json:"email_address"`
}
backend/internal/pkg/response/response.go
View file @
0170d19f
...
...
@@ -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
}
...
...
@@ -162,11 +169,11 @@ func ParsePagination(c *gin.Context) (page, pageSize int) {
// 支持 page_size 和 limit 两种参数名
if
ps
:=
c
.
Query
(
"page_size"
);
ps
!=
""
{
if
val
,
err
:=
parseInt
(
ps
);
err
==
nil
&&
val
>
0
&&
val
<=
100
{
if
val
,
err
:=
parseInt
(
ps
);
err
==
nil
&&
val
>
0
&&
val
<=
100
0
{
pageSize
=
val
}
}
else
if
l
:=
c
.
Query
(
"limit"
);
l
!=
""
{
if
val
,
err
:=
parseInt
(
l
);
err
==
nil
&&
val
>
0
&&
val
<=
100
{
if
val
,
err
:=
parseInt
(
l
);
err
==
nil
&&
val
>
0
&&
val
<=
100
0
{
pageSize
=
val
}
}
...
...
Prev
1
2
3
4
5
6
7
8
9
…
16
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