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
2fe8932c
"frontend/src/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "260c152166717747302797cc09c6c76be73de31a"
Unverified
Commit
2fe8932c
authored
Feb 03, 2026
by
Call White
Committed by
GitHub
Feb 03, 2026
Browse files
Merge pull request #3 from cyhhao/main
merge to main
parents
2f2e76f9
adb77af1
Changes
267
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/admin_service_stub_test.go
0 → 100644
View file @
2fe8932c
package
admin
import
(
"context"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type
stubAdminService
struct
{
users
[]
service
.
User
apiKeys
[]
service
.
APIKey
groups
[]
service
.
Group
accounts
[]
service
.
Account
proxies
[]
service
.
Proxy
proxyCounts
[]
service
.
ProxyWithAccountCount
redeems
[]
service
.
RedeemCode
}
func
newStubAdminService
()
*
stubAdminService
{
now
:=
time
.
Now
()
.
UTC
()
user
:=
service
.
User
{
ID
:
1
,
Email
:
"user@example.com"
,
Role
:
service
.
RoleUser
,
Status
:
service
.
StatusActive
,
CreatedAt
:
now
,
UpdatedAt
:
now
,
}
apiKey
:=
service
.
APIKey
{
ID
:
10
,
UserID
:
user
.
ID
,
Key
:
"sk-test"
,
Name
:
"test"
,
Status
:
service
.
StatusActive
,
CreatedAt
:
now
,
UpdatedAt
:
now
,
}
group
:=
service
.
Group
{
ID
:
2
,
Name
:
"group"
,
Platform
:
service
.
PlatformAnthropic
,
Status
:
service
.
StatusActive
,
CreatedAt
:
now
,
UpdatedAt
:
now
,
}
account
:=
service
.
Account
{
ID
:
3
,
Name
:
"account"
,
Platform
:
service
.
PlatformAnthropic
,
Type
:
service
.
AccountTypeOAuth
,
Status
:
service
.
StatusActive
,
CreatedAt
:
now
,
UpdatedAt
:
now
,
}
proxy
:=
service
.
Proxy
{
ID
:
4
,
Name
:
"proxy"
,
Protocol
:
"http"
,
Host
:
"127.0.0.1"
,
Port
:
8080
,
Status
:
service
.
StatusActive
,
CreatedAt
:
now
,
UpdatedAt
:
now
,
}
redeem
:=
service
.
RedeemCode
{
ID
:
5
,
Code
:
"R-TEST"
,
Type
:
service
.
RedeemTypeBalance
,
Value
:
10
,
Status
:
service
.
StatusUnused
,
CreatedAt
:
now
,
}
return
&
stubAdminService
{
users
:
[]
service
.
User
{
user
},
apiKeys
:
[]
service
.
APIKey
{
apiKey
},
groups
:
[]
service
.
Group
{
group
},
accounts
:
[]
service
.
Account
{
account
},
proxies
:
[]
service
.
Proxy
{
proxy
},
proxyCounts
:
[]
service
.
ProxyWithAccountCount
{{
Proxy
:
proxy
,
AccountCount
:
1
}},
redeems
:
[]
service
.
RedeemCode
{
redeem
},
}
}
func
(
s
*
stubAdminService
)
ListUsers
(
ctx
context
.
Context
,
page
,
pageSize
int
,
filters
service
.
UserListFilters
)
([]
service
.
User
,
int64
,
error
)
{
return
s
.
users
,
int64
(
len
(
s
.
users
)),
nil
}
func
(
s
*
stubAdminService
)
GetUser
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
User
,
error
)
{
for
i
:=
range
s
.
users
{
if
s
.
users
[
i
]
.
ID
==
id
{
return
&
s
.
users
[
i
],
nil
}
}
user
:=
service
.
User
{
ID
:
id
,
Email
:
"user@example.com"
,
Status
:
service
.
StatusActive
}
return
&
user
,
nil
}
func
(
s
*
stubAdminService
)
CreateUser
(
ctx
context
.
Context
,
input
*
service
.
CreateUserInput
)
(
*
service
.
User
,
error
)
{
user
:=
service
.
User
{
ID
:
100
,
Email
:
input
.
Email
,
Status
:
service
.
StatusActive
}
return
&
user
,
nil
}
func
(
s
*
stubAdminService
)
UpdateUser
(
ctx
context
.
Context
,
id
int64
,
input
*
service
.
UpdateUserInput
)
(
*
service
.
User
,
error
)
{
user
:=
service
.
User
{
ID
:
id
,
Email
:
"updated@example.com"
,
Status
:
service
.
StatusActive
}
return
&
user
,
nil
}
func
(
s
*
stubAdminService
)
DeleteUser
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
UpdateUserBalance
(
ctx
context
.
Context
,
userID
int64
,
balance
float64
,
operation
string
,
notes
string
)
(
*
service
.
User
,
error
)
{
user
:=
service
.
User
{
ID
:
userID
,
Balance
:
balance
,
Status
:
service
.
StatusActive
}
return
&
user
,
nil
}
func
(
s
*
stubAdminService
)
GetUserAPIKeys
(
ctx
context
.
Context
,
userID
int64
,
page
,
pageSize
int
)
([]
service
.
APIKey
,
int64
,
error
)
{
return
s
.
apiKeys
,
int64
(
len
(
s
.
apiKeys
)),
nil
}
func
(
s
*
stubAdminService
)
GetUserUsageStats
(
ctx
context
.
Context
,
userID
int64
,
period
string
)
(
any
,
error
)
{
return
map
[
string
]
any
{
"user_id"
:
userID
},
nil
}
func
(
s
*
stubAdminService
)
ListGroups
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
status
,
search
string
,
isExclusive
*
bool
)
([]
service
.
Group
,
int64
,
error
)
{
return
s
.
groups
,
int64
(
len
(
s
.
groups
)),
nil
}
func
(
s
*
stubAdminService
)
GetAllGroups
(
ctx
context
.
Context
)
([]
service
.
Group
,
error
)
{
return
s
.
groups
,
nil
}
func
(
s
*
stubAdminService
)
GetAllGroupsByPlatform
(
ctx
context
.
Context
,
platform
string
)
([]
service
.
Group
,
error
)
{
return
s
.
groups
,
nil
}
func
(
s
*
stubAdminService
)
GetGroup
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Group
,
error
)
{
group
:=
service
.
Group
{
ID
:
id
,
Name
:
"group"
,
Status
:
service
.
StatusActive
}
return
&
group
,
nil
}
func
(
s
*
stubAdminService
)
CreateGroup
(
ctx
context
.
Context
,
input
*
service
.
CreateGroupInput
)
(
*
service
.
Group
,
error
)
{
group
:=
service
.
Group
{
ID
:
200
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
group
,
nil
}
func
(
s
*
stubAdminService
)
UpdateGroup
(
ctx
context
.
Context
,
id
int64
,
input
*
service
.
UpdateGroupInput
)
(
*
service
.
Group
,
error
)
{
group
:=
service
.
Group
{
ID
:
id
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
group
,
nil
}
func
(
s
*
stubAdminService
)
DeleteGroup
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
GetGroupAPIKeys
(
ctx
context
.
Context
,
groupID
int64
,
page
,
pageSize
int
)
([]
service
.
APIKey
,
int64
,
error
)
{
return
s
.
apiKeys
,
int64
(
len
(
s
.
apiKeys
)),
nil
}
func
(
s
*
stubAdminService
)
ListAccounts
(
ctx
context
.
Context
,
page
,
pageSize
int
,
platform
,
accountType
,
status
,
search
string
)
([]
service
.
Account
,
int64
,
error
)
{
return
s
.
accounts
,
int64
(
len
(
s
.
accounts
)),
nil
}
func
(
s
*
stubAdminService
)
GetAccount
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
"account"
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
GetAccountsByIDs
(
ctx
context
.
Context
,
ids
[]
int64
)
([]
*
service
.
Account
,
error
)
{
out
:=
make
([]
*
service
.
Account
,
0
,
len
(
ids
))
for
_
,
id
:=
range
ids
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
"account"
,
Status
:
service
.
StatusActive
}
out
=
append
(
out
,
&
account
)
}
return
out
,
nil
}
func
(
s
*
stubAdminService
)
CreateAccount
(
ctx
context
.
Context
,
input
*
service
.
CreateAccountInput
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
300
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
UpdateAccount
(
ctx
context
.
Context
,
id
int64
,
input
*
service
.
UpdateAccountInput
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
DeleteAccount
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
RefreshAccountCredentials
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
"account"
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
ClearAccountError
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
"account"
,
Status
:
service
.
StatusActive
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
SetAccountError
(
ctx
context
.
Context
,
id
int64
,
errorMsg
string
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
SetAccountSchedulable
(
ctx
context
.
Context
,
id
int64
,
schedulable
bool
)
(
*
service
.
Account
,
error
)
{
account
:=
service
.
Account
{
ID
:
id
,
Name
:
"account"
,
Status
:
service
.
StatusActive
,
Schedulable
:
schedulable
}
return
&
account
,
nil
}
func
(
s
*
stubAdminService
)
BulkUpdateAccounts
(
ctx
context
.
Context
,
input
*
service
.
BulkUpdateAccountsInput
)
(
*
service
.
BulkUpdateAccountsResult
,
error
)
{
return
&
service
.
BulkUpdateAccountsResult
{
Success
:
1
,
Failed
:
0
,
SuccessIDs
:
[]
int64
{
1
}},
nil
}
func
(
s
*
stubAdminService
)
ListProxies
(
ctx
context
.
Context
,
page
,
pageSize
int
,
protocol
,
status
,
search
string
)
([]
service
.
Proxy
,
int64
,
error
)
{
return
s
.
proxies
,
int64
(
len
(
s
.
proxies
)),
nil
}
func
(
s
*
stubAdminService
)
ListProxiesWithAccountCount
(
ctx
context
.
Context
,
page
,
pageSize
int
,
protocol
,
status
,
search
string
)
([]
service
.
ProxyWithAccountCount
,
int64
,
error
)
{
return
s
.
proxyCounts
,
int64
(
len
(
s
.
proxyCounts
)),
nil
}
func
(
s
*
stubAdminService
)
GetAllProxies
(
ctx
context
.
Context
)
([]
service
.
Proxy
,
error
)
{
return
s
.
proxies
,
nil
}
func
(
s
*
stubAdminService
)
GetAllProxiesWithAccountCount
(
ctx
context
.
Context
)
([]
service
.
ProxyWithAccountCount
,
error
)
{
return
s
.
proxyCounts
,
nil
}
func
(
s
*
stubAdminService
)
GetProxy
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
Proxy
,
error
)
{
proxy
:=
service
.
Proxy
{
ID
:
id
,
Name
:
"proxy"
,
Status
:
service
.
StatusActive
}
return
&
proxy
,
nil
}
func
(
s
*
stubAdminService
)
CreateProxy
(
ctx
context
.
Context
,
input
*
service
.
CreateProxyInput
)
(
*
service
.
Proxy
,
error
)
{
proxy
:=
service
.
Proxy
{
ID
:
400
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
proxy
,
nil
}
func
(
s
*
stubAdminService
)
UpdateProxy
(
ctx
context
.
Context
,
id
int64
,
input
*
service
.
UpdateProxyInput
)
(
*
service
.
Proxy
,
error
)
{
proxy
:=
service
.
Proxy
{
ID
:
id
,
Name
:
input
.
Name
,
Status
:
service
.
StatusActive
}
return
&
proxy
,
nil
}
func
(
s
*
stubAdminService
)
DeleteProxy
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
BatchDeleteProxies
(
ctx
context
.
Context
,
ids
[]
int64
)
(
*
service
.
ProxyBatchDeleteResult
,
error
)
{
return
&
service
.
ProxyBatchDeleteResult
{
DeletedIDs
:
ids
},
nil
}
func
(
s
*
stubAdminService
)
GetProxyAccounts
(
ctx
context
.
Context
,
proxyID
int64
)
([]
service
.
ProxyAccountSummary
,
error
)
{
return
[]
service
.
ProxyAccountSummary
{{
ID
:
1
,
Name
:
"account"
}},
nil
}
func
(
s
*
stubAdminService
)
CheckProxyExists
(
ctx
context
.
Context
,
host
string
,
port
int
,
username
,
password
string
)
(
bool
,
error
)
{
return
false
,
nil
}
func
(
s
*
stubAdminService
)
TestProxy
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
ProxyTestResult
,
error
)
{
return
&
service
.
ProxyTestResult
{
Success
:
true
,
Message
:
"ok"
},
nil
}
func
(
s
*
stubAdminService
)
ListRedeemCodes
(
ctx
context
.
Context
,
page
,
pageSize
int
,
codeType
,
status
,
search
string
)
([]
service
.
RedeemCode
,
int64
,
error
)
{
return
s
.
redeems
,
int64
(
len
(
s
.
redeems
)),
nil
}
func
(
s
*
stubAdminService
)
GetRedeemCode
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
RedeemCode
,
error
)
{
code
:=
service
.
RedeemCode
{
ID
:
id
,
Code
:
"R-TEST"
,
Status
:
service
.
StatusUnused
}
return
&
code
,
nil
}
func
(
s
*
stubAdminService
)
GenerateRedeemCodes
(
ctx
context
.
Context
,
input
*
service
.
GenerateRedeemCodesInput
)
([]
service
.
RedeemCode
,
error
)
{
return
s
.
redeems
,
nil
}
func
(
s
*
stubAdminService
)
DeleteRedeemCode
(
ctx
context
.
Context
,
id
int64
)
error
{
return
nil
}
func
(
s
*
stubAdminService
)
BatchDeleteRedeemCodes
(
ctx
context
.
Context
,
ids
[]
int64
)
(
int64
,
error
)
{
return
int64
(
len
(
ids
)),
nil
}
func
(
s
*
stubAdminService
)
ExpireRedeemCode
(
ctx
context
.
Context
,
id
int64
)
(
*
service
.
RedeemCode
,
error
)
{
code
:=
service
.
RedeemCode
{
ID
:
id
,
Code
:
"R-TEST"
,
Status
:
service
.
StatusUsed
}
return
&
code
,
nil
}
// Ensure stub implements interface.
var
_
service
.
AdminService
=
(
*
stubAdminService
)(
nil
)
backend/internal/handler/admin/dashboard_handler.go
View file @
2fe8932c
...
@@ -186,7 +186,7 @@ func (h *DashboardHandler) GetRealtimeMetrics(c *gin.Context) {
...
@@ -186,7 +186,7 @@ func (h *DashboardHandler) GetRealtimeMetrics(c *gin.Context) {
// GetUsageTrend handles getting usage trend data
// GetUsageTrend handles getting usage trend data
// GET /api/v1/admin/dashboard/trend
// GET /api/v1/admin/dashboard/trend
// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id, model, account_id, group_id, stream
// Query params: start_date, end_date (YYYY-MM-DD), granularity (day/hour), user_id, api_key_id, model, account_id, group_id, stream
, billing_type
func
(
h
*
DashboardHandler
)
GetUsageTrend
(
c
*
gin
.
Context
)
{
func
(
h
*
DashboardHandler
)
GetUsageTrend
(
c
*
gin
.
Context
)
{
startTime
,
endTime
:=
parseTimeRange
(
c
)
startTime
,
endTime
:=
parseTimeRange
(
c
)
granularity
:=
c
.
DefaultQuery
(
"granularity"
,
"day"
)
granularity
:=
c
.
DefaultQuery
(
"granularity"
,
"day"
)
...
@@ -195,6 +195,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
...
@@ -195,6 +195,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
var
model
string
var
model
string
var
stream
*
bool
var
stream
*
bool
var
billingType
*
int8
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
);
err
==
nil
{
if
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
);
err
==
nil
{
...
@@ -224,8 +225,17 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
...
@@ -224,8 +225,17 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
stream
=
&
streamVal
stream
=
&
streamVal
}
}
}
}
if
billingTypeStr
:=
c
.
Query
(
"billing_type"
);
billingTypeStr
!=
""
{
if
v
,
err
:=
strconv
.
ParseInt
(
billingTypeStr
,
10
,
8
);
err
==
nil
{
bt
:=
int8
(
v
)
billingType
=
&
bt
}
else
{
response
.
BadRequest
(
c
,
"Invalid billing_type"
)
return
}
}
trend
,
err
:=
h
.
dashboardService
.
GetUsageTrendWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
granularity
,
userID
,
apiKeyID
,
accountID
,
groupID
,
model
,
stream
)
trend
,
err
:=
h
.
dashboardService
.
GetUsageTrendWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
granularity
,
userID
,
apiKeyID
,
accountID
,
groupID
,
model
,
stream
,
billingType
)
if
err
!=
nil
{
if
err
!=
nil
{
response
.
Error
(
c
,
500
,
"Failed to get usage trend"
)
response
.
Error
(
c
,
500
,
"Failed to get usage trend"
)
return
return
...
@@ -241,13 +251,14 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
...
@@ -241,13 +251,14 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
// GetModelStats handles getting model usage statistics
// GetModelStats handles getting model usage statistics
// GET /api/v1/admin/dashboard/models
// GET /api/v1/admin/dashboard/models
// Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id, account_id, group_id, stream
// Query params: start_date, end_date (YYYY-MM-DD), user_id, api_key_id, account_id, group_id, stream
, billing_type
func
(
h
*
DashboardHandler
)
GetModelStats
(
c
*
gin
.
Context
)
{
func
(
h
*
DashboardHandler
)
GetModelStats
(
c
*
gin
.
Context
)
{
startTime
,
endTime
:=
parseTimeRange
(
c
)
startTime
,
endTime
:=
parseTimeRange
(
c
)
// Parse optional filter params
// Parse optional filter params
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
var
stream
*
bool
var
stream
*
bool
var
billingType
*
int8
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
);
err
==
nil
{
if
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
);
err
==
nil
{
...
@@ -274,8 +285,17 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
...
@@ -274,8 +285,17 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
stream
=
&
streamVal
stream
=
&
streamVal
}
}
}
}
if
billingTypeStr
:=
c
.
Query
(
"billing_type"
);
billingTypeStr
!=
""
{
if
v
,
err
:=
strconv
.
ParseInt
(
billingTypeStr
,
10
,
8
);
err
==
nil
{
bt
:=
int8
(
v
)
billingType
=
&
bt
}
else
{
response
.
BadRequest
(
c
,
"Invalid billing_type"
)
return
}
}
stats
,
err
:=
h
.
dashboardService
.
GetModelStatsWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
userID
,
apiKeyID
,
accountID
,
groupID
,
stream
)
stats
,
err
:=
h
.
dashboardService
.
GetModelStatsWithFilters
(
c
.
Request
.
Context
(),
startTime
,
endTime
,
userID
,
apiKeyID
,
accountID
,
groupID
,
stream
,
billingType
)
if
err
!=
nil
{
if
err
!=
nil
{
response
.
Error
(
c
,
500
,
"Failed to get model statistics"
)
response
.
Error
(
c
,
500
,
"Failed to get model statistics"
)
return
return
...
...
backend/internal/handler/admin/group_handler.go
View file @
2fe8932c
...
@@ -94,9 +94,9 @@ func (h *GroupHandler) List(c *gin.Context) {
...
@@ -94,9 +94,9 @@ func (h *GroupHandler) List(c *gin.Context) {
return
return
}
}
outGroups
:=
make
([]
dto
.
Group
,
0
,
len
(
groups
))
outGroups
:=
make
([]
dto
.
Admin
Group
,
0
,
len
(
groups
))
for
i
:=
range
groups
{
for
i
:=
range
groups
{
outGroups
=
append
(
outGroups
,
*
dto
.
GroupFromService
(
&
groups
[
i
]))
outGroups
=
append
(
outGroups
,
*
dto
.
GroupFromService
Admin
(
&
groups
[
i
]))
}
}
response
.
Paginated
(
c
,
outGroups
,
total
,
page
,
pageSize
)
response
.
Paginated
(
c
,
outGroups
,
total
,
page
,
pageSize
)
}
}
...
@@ -120,9 +120,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
...
@@ -120,9 +120,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
return
return
}
}
outGroups
:=
make
([]
dto
.
Group
,
0
,
len
(
groups
))
outGroups
:=
make
([]
dto
.
Admin
Group
,
0
,
len
(
groups
))
for
i
:=
range
groups
{
for
i
:=
range
groups
{
outGroups
=
append
(
outGroups
,
*
dto
.
GroupFromService
(
&
groups
[
i
]))
outGroups
=
append
(
outGroups
,
*
dto
.
GroupFromService
Admin
(
&
groups
[
i
]))
}
}
response
.
Success
(
c
,
outGroups
)
response
.
Success
(
c
,
outGroups
)
}
}
...
@@ -142,7 +142,7 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
...
@@ -142,7 +142,7 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
GroupFromService
(
group
))
response
.
Success
(
c
,
dto
.
GroupFromService
Admin
(
group
))
}
}
// Create handles creating a new group
// Create handles creating a new group
...
@@ -177,7 +177,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
...
@@ -177,7 +177,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
GroupFromService
(
group
))
response
.
Success
(
c
,
dto
.
GroupFromService
Admin
(
group
))
}
}
// Update handles updating a group
// Update handles updating a group
...
@@ -219,7 +219,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
...
@@ -219,7 +219,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
GroupFromService
(
group
))
response
.
Success
(
c
,
dto
.
GroupFromService
Admin
(
group
))
}
}
// Delete handles deleting a group
// Delete handles deleting a group
...
...
backend/internal/handler/admin/redeem_handler.go
View file @
2fe8932c
...
@@ -54,9 +54,9 @@ func (h *RedeemHandler) List(c *gin.Context) {
...
@@ -54,9 +54,9 @@ func (h *RedeemHandler) List(c *gin.Context) {
return
return
}
}
out
:=
make
([]
dto
.
RedeemCode
,
0
,
len
(
codes
))
out
:=
make
([]
dto
.
Admin
RedeemCode
,
0
,
len
(
codes
))
for
i
:=
range
codes
{
for
i
:=
range
codes
{
out
=
append
(
out
,
*
dto
.
RedeemCodeFromService
(
&
codes
[
i
]))
out
=
append
(
out
,
*
dto
.
RedeemCodeFromService
Admin
(
&
codes
[
i
]))
}
}
response
.
Paginated
(
c
,
out
,
total
,
page
,
pageSize
)
response
.
Paginated
(
c
,
out
,
total
,
page
,
pageSize
)
}
}
...
@@ -76,7 +76,7 @@ func (h *RedeemHandler) GetByID(c *gin.Context) {
...
@@ -76,7 +76,7 @@ func (h *RedeemHandler) GetByID(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
(
code
))
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
Admin
(
code
))
}
}
// Generate handles generating new redeem codes
// Generate handles generating new redeem codes
...
@@ -100,9 +100,9 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
...
@@ -100,9 +100,9 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
return
return
}
}
out
:=
make
([]
dto
.
RedeemCode
,
0
,
len
(
codes
))
out
:=
make
([]
dto
.
Admin
RedeemCode
,
0
,
len
(
codes
))
for
i
:=
range
codes
{
for
i
:=
range
codes
{
out
=
append
(
out
,
*
dto
.
RedeemCodeFromService
(
&
codes
[
i
]))
out
=
append
(
out
,
*
dto
.
RedeemCodeFromService
Admin
(
&
codes
[
i
]))
}
}
response
.
Success
(
c
,
out
)
response
.
Success
(
c
,
out
)
}
}
...
@@ -163,7 +163,7 @@ func (h *RedeemHandler) Expire(c *gin.Context) {
...
@@ -163,7 +163,7 @@ func (h *RedeemHandler) Expire(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
(
code
))
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
Admin
(
code
))
}
}
// GetStats handles getting redeem code statistics
// GetStats handles getting redeem code statistics
...
...
backend/internal/handler/admin/setting_handler.go
View file @
2fe8932c
...
@@ -47,6 +47,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
...
@@ -47,6 +47,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
response
.
Success
(
c
,
dto
.
SystemSettings
{
response
.
Success
(
c
,
dto
.
SystemSettings
{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
settings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
settings
.
PasswordResetEnabled
,
TotpEnabled
:
settings
.
TotpEnabled
,
TotpEncryptionKeyConfigured
:
h
.
settingService
.
IsTotpEncryptionKeyConfigured
(),
SMTPHost
:
settings
.
SMTPHost
,
SMTPHost
:
settings
.
SMTPHost
,
SMTPPort
:
settings
.
SMTPPort
,
SMTPPort
:
settings
.
SMTPPort
,
SMTPUsername
:
settings
.
SMTPUsername
,
SMTPUsername
:
settings
.
SMTPUsername
,
...
@@ -68,6 +72,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
...
@@ -68,6 +72,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ContactInfo
:
settings
.
ContactInfo
,
ContactInfo
:
settings
.
ContactInfo
,
DocURL
:
settings
.
DocURL
,
DocURL
:
settings
.
DocURL
,
HomeContent
:
settings
.
HomeContent
,
HomeContent
:
settings
.
HomeContent
,
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
DefaultBalance
:
settings
.
DefaultBalance
,
DefaultBalance
:
settings
.
DefaultBalance
,
EnableModelFallback
:
settings
.
EnableModelFallback
,
EnableModelFallback
:
settings
.
EnableModelFallback
,
...
@@ -87,8 +94,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
...
@@ -87,8 +94,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
// UpdateSettingsRequest 更新设置请求
// UpdateSettingsRequest 更新设置请求
type
UpdateSettingsRequest
struct
{
type
UpdateSettingsRequest
struct
{
// 注册设置
// 注册设置
RegistrationEnabled
bool
`json:"registration_enabled"`
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
// TOTP 双因素认证
// 邮件服务设置
// 邮件服务设置
SMTPHost
string
`json:"smtp_host"`
SMTPHost
string
`json:"smtp_host"`
...
@@ -111,13 +121,16 @@ type UpdateSettingsRequest struct {
...
@@ -111,13 +121,16 @@ type UpdateSettingsRequest struct {
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
// OEM设置
// OEM设置
SiteName
string
`json:"site_name"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
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"`
DefaultConcurrency
int
`json:"default_concurrency"`
...
@@ -194,6 +207,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -194,6 +207,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
}
}
// TOTP 双因素认证参数验证
// 只有手动配置了加密密钥才允许启用 TOTP 功能
if
req
.
TotpEnabled
&&
!
previousSettings
.
TotpEnabled
{
// 尝试启用 TOTP,检查加密密钥是否已手动配置
if
!
h
.
settingService
.
IsTotpEncryptionKeyConfigured
()
{
response
.
BadRequest
(
c
,
"Cannot enable TOTP: TOTP_ENCRYPTION_KEY environment variable must be configured first. Generate a key with 'openssl rand -hex 32' and set it in your environment."
)
return
}
}
// LinuxDo Connect 参数验证
// LinuxDo Connect 参数验证
if
req
.
LinuxDoConnectEnabled
{
if
req
.
LinuxDoConnectEnabled
{
req
.
LinuxDoConnectClientID
=
strings
.
TrimSpace
(
req
.
LinuxDoConnectClientID
)
req
.
LinuxDoConnectClientID
=
strings
.
TrimSpace
(
req
.
LinuxDoConnectClientID
)
...
@@ -223,6 +246,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -223,6 +246,34 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
}
}
// “购买订阅”页面配置验证
purchaseEnabled
:=
previousSettings
.
PurchaseSubscriptionEnabled
if
req
.
PurchaseSubscriptionEnabled
!=
nil
{
purchaseEnabled
=
*
req
.
PurchaseSubscriptionEnabled
}
purchaseURL
:=
previousSettings
.
PurchaseSubscriptionURL
if
req
.
PurchaseSubscriptionURL
!=
nil
{
purchaseURL
=
strings
.
TrimSpace
(
*
req
.
PurchaseSubscriptionURL
)
}
// - 启用时要求 URL 合法且非空
// - 禁用时允许为空;若提供了 URL 也做基本校验,避免误配置
if
purchaseEnabled
{
if
purchaseURL
==
""
{
response
.
BadRequest
(
c
,
"Purchase Subscription URL is required when enabled"
)
return
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
purchaseURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Purchase Subscription URL must be an absolute http(s) URL"
)
return
}
}
else
if
purchaseURL
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
purchaseURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Purchase Subscription URL must be an absolute http(s) URL"
)
return
}
}
// Ops metrics collector interval validation (seconds).
// Ops metrics collector interval validation (seconds).
if
req
.
OpsMetricsIntervalSeconds
!=
nil
{
if
req
.
OpsMetricsIntervalSeconds
!=
nil
{
v
:=
*
req
.
OpsMetricsIntervalSeconds
v
:=
*
req
.
OpsMetricsIntervalSeconds
...
@@ -236,38 +287,44 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -236,38 +287,44 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
settings
:=
&
service
.
SystemSettings
{
settings
:=
&
service
.
SystemSettings
{
RegistrationEnabled
:
req
.
RegistrationEnabled
,
RegistrationEnabled
:
req
.
RegistrationEnabled
,
EmailVerifyEnabled
:
req
.
EmailVerifyEnabled
,
EmailVerifyEnabled
:
req
.
EmailVerifyEnabled
,
SMTPHost
:
req
.
SMTPHost
,
PromoCodeEnabled
:
req
.
PromoCodeEnabled
,
SMTPPort
:
req
.
SMTPPort
,
PasswordResetEnabled
:
req
.
PasswordResetEnabled
,
SMTPUsername
:
req
.
SMTPUsername
,
TotpEnabled
:
req
.
TotpEnabled
,
SMTPPassword
:
req
.
SMTPPassword
,
SMTPHost
:
req
.
SMTPHost
,
SMTPFrom
:
req
.
SMTPFrom
,
SMTPPort
:
req
.
SMTPPort
,
SMTPFromName
:
req
.
SMTPFromName
,
SMTPUsername
:
req
.
SMTPUsername
,
SMTPUseTLS
:
req
.
SMTPUseTLS
,
SMTPPassword
:
req
.
SMTPPassword
,
TurnstileEnabled
:
req
.
TurnstileEnabled
,
SMTPFrom
:
req
.
SMTPFrom
,
TurnstileSiteKey
:
req
.
TurnstileSiteKey
,
SMTPFromName
:
req
.
SMTPFromName
,
TurnstileSecretKey
:
req
.
TurnstileSecretKey
,
SMTPUseTLS
:
req
.
SMTPUseTLS
,
LinuxDoConnectEnabled
:
req
.
LinuxDoConnectEnabled
,
TurnstileEnabled
:
req
.
TurnstileEnabled
,
LinuxDoConnectClientID
:
req
.
LinuxDoConnectClientID
,
TurnstileSiteKey
:
req
.
TurnstileSiteKey
,
LinuxDoConnectClientSecret
:
req
.
LinuxDoConnectClientSecret
,
TurnstileSecretKey
:
req
.
TurnstileSecretKey
,
LinuxDoConnectRedirectURL
:
req
.
LinuxDoConnectRedirectURL
,
LinuxDoConnectEnabled
:
req
.
LinuxDoConnectEnabled
,
SiteName
:
req
.
SiteName
,
LinuxDoConnectClientID
:
req
.
LinuxDoConnectClientID
,
SiteLogo
:
req
.
SiteLogo
,
LinuxDoConnectClientSecret
:
req
.
LinuxDoConnectClientSecret
,
SiteSubtitle
:
req
.
SiteSubtitle
,
LinuxDoConnectRedirectURL
:
req
.
LinuxDoConnectRedirectURL
,
APIBaseURL
:
req
.
APIBaseURL
,
SiteName
:
req
.
SiteName
,
ContactInfo
:
req
.
ContactInfo
,
SiteLogo
:
req
.
SiteLogo
,
DocURL
:
req
.
DocURL
,
SiteSubtitle
:
req
.
SiteSubtitle
,
HomeContent
:
req
.
HomeContent
,
APIBaseURL
:
req
.
APIBaseURL
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
ContactInfo
:
req
.
ContactInfo
,
DefaultBalance
:
req
.
DefaultBalance
,
DocURL
:
req
.
DocURL
,
EnableModelFallback
:
req
.
EnableModelFallback
,
HomeContent
:
req
.
HomeContent
,
FallbackModelAnthropic
:
req
.
FallbackModelAnthropic
,
HideCcsImportButton
:
req
.
HideCcsImportButton
,
FallbackModelOpenAI
:
req
.
FallbackModelOpenAI
,
PurchaseSubscriptionEnabled
:
purchaseEnabled
,
FallbackModelGemini
:
req
.
FallbackModelGemini
,
PurchaseSubscriptionURL
:
purchaseURL
,
FallbackModelAntigravity
:
req
.
FallbackModelAntigravity
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
EnableIdentityPatch
:
req
.
EnableIdentityPatch
,
DefaultBalance
:
req
.
DefaultBalance
,
IdentityPatchPrompt
:
req
.
IdentityPatchPrompt
,
EnableModelFallback
:
req
.
EnableModelFallback
,
FallbackModelAnthropic
:
req
.
FallbackModelAnthropic
,
FallbackModelOpenAI
:
req
.
FallbackModelOpenAI
,
FallbackModelGemini
:
req
.
FallbackModelGemini
,
FallbackModelAntigravity
:
req
.
FallbackModelAntigravity
,
EnableIdentityPatch
:
req
.
EnableIdentityPatch
,
IdentityPatchPrompt
:
req
.
IdentityPatchPrompt
,
OpsMonitoringEnabled
:
func
()
bool
{
OpsMonitoringEnabled
:
func
()
bool
{
if
req
.
OpsMonitoringEnabled
!=
nil
{
if
req
.
OpsMonitoringEnabled
!=
nil
{
return
*
req
.
OpsMonitoringEnabled
return
*
req
.
OpsMonitoringEnabled
...
@@ -311,6 +368,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -311,6 +368,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response
.
Success
(
c
,
dto
.
SystemSettings
{
response
.
Success
(
c
,
dto
.
SystemSettings
{
RegistrationEnabled
:
updatedSettings
.
RegistrationEnabled
,
RegistrationEnabled
:
updatedSettings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
updatedSettings
.
EmailVerifyEnabled
,
EmailVerifyEnabled
:
updatedSettings
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
updatedSettings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
updatedSettings
.
PasswordResetEnabled
,
TotpEnabled
:
updatedSettings
.
TotpEnabled
,
TotpEncryptionKeyConfigured
:
h
.
settingService
.
IsTotpEncryptionKeyConfigured
(),
SMTPHost
:
updatedSettings
.
SMTPHost
,
SMTPHost
:
updatedSettings
.
SMTPHost
,
SMTPPort
:
updatedSettings
.
SMTPPort
,
SMTPPort
:
updatedSettings
.
SMTPPort
,
SMTPUsername
:
updatedSettings
.
SMTPUsername
,
SMTPUsername
:
updatedSettings
.
SMTPUsername
,
...
@@ -332,6 +393,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
...
@@ -332,6 +393,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ContactInfo
:
updatedSettings
.
ContactInfo
,
ContactInfo
:
updatedSettings
.
ContactInfo
,
DocURL
:
updatedSettings
.
DocURL
,
DocURL
:
updatedSettings
.
DocURL
,
HomeContent
:
updatedSettings
.
HomeContent
,
HomeContent
:
updatedSettings
.
HomeContent
,
HideCcsImportButton
:
updatedSettings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
updatedSettings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
updatedSettings
.
PurchaseSubscriptionURL
,
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
DefaultBalance
:
updatedSettings
.
DefaultBalance
,
DefaultBalance
:
updatedSettings
.
DefaultBalance
,
EnableModelFallback
:
updatedSettings
.
EnableModelFallback
,
EnableModelFallback
:
updatedSettings
.
EnableModelFallback
,
...
@@ -376,6 +440,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
...
@@ -376,6 +440,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
EmailVerifyEnabled
!=
after
.
EmailVerifyEnabled
{
if
before
.
EmailVerifyEnabled
!=
after
.
EmailVerifyEnabled
{
changed
=
append
(
changed
,
"email_verify_enabled"
)
changed
=
append
(
changed
,
"email_verify_enabled"
)
}
}
if
before
.
PasswordResetEnabled
!=
after
.
PasswordResetEnabled
{
changed
=
append
(
changed
,
"password_reset_enabled"
)
}
if
before
.
TotpEnabled
!=
after
.
TotpEnabled
{
changed
=
append
(
changed
,
"totp_enabled"
)
}
if
before
.
SMTPHost
!=
after
.
SMTPHost
{
if
before
.
SMTPHost
!=
after
.
SMTPHost
{
changed
=
append
(
changed
,
"smtp_host"
)
changed
=
append
(
changed
,
"smtp_host"
)
}
}
...
@@ -439,6 +509,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
...
@@ -439,6 +509,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
HomeContent
!=
after
.
HomeContent
{
if
before
.
HomeContent
!=
after
.
HomeContent
{
changed
=
append
(
changed
,
"home_content"
)
changed
=
append
(
changed
,
"home_content"
)
}
}
if
before
.
HideCcsImportButton
!=
after
.
HideCcsImportButton
{
changed
=
append
(
changed
,
"hide_ccs_import_button"
)
}
if
before
.
DefaultConcurrency
!=
after
.
DefaultConcurrency
{
if
before
.
DefaultConcurrency
!=
after
.
DefaultConcurrency
{
changed
=
append
(
changed
,
"default_concurrency"
)
changed
=
append
(
changed
,
"default_concurrency"
)
}
}
...
...
backend/internal/handler/admin/subscription_handler.go
View file @
2fe8932c
...
@@ -53,9 +53,9 @@ type BulkAssignSubscriptionRequest struct {
...
@@ -53,9 +53,9 @@ type BulkAssignSubscriptionRequest struct {
Notes
string
`json:"notes"`
Notes
string
`json:"notes"`
}
}
//
Extend
SubscriptionRequest represents
extend
subscription request
//
Adjust
SubscriptionRequest represents
adjust
subscription request
(extend or shorten)
type
Extend
SubscriptionRequest
struct
{
type
Adjust
SubscriptionRequest
struct
{
Days
int
`json:"days" binding:"required,min=
1
,max=36500"`
//
max 100 years
Days
int
`json:"days" binding:"required,min=
-36500
,max=36500"`
//
negative to shorten, positive to extend
}
}
// List handles listing all subscriptions with pagination and filters
// List handles listing all subscriptions with pagination and filters
...
@@ -77,15 +77,19 @@ func (h *SubscriptionHandler) List(c *gin.Context) {
...
@@ -77,15 +77,19 @@ func (h *SubscriptionHandler) List(c *gin.Context) {
}
}
status
:=
c
.
Query
(
"status"
)
status
:=
c
.
Query
(
"status"
)
subscriptions
,
pagination
,
err
:=
h
.
subscriptionService
.
List
(
c
.
Request
.
Context
(),
page
,
pageSize
,
userID
,
groupID
,
status
)
// Parse sorting parameters
sortBy
:=
c
.
DefaultQuery
(
"sort_by"
,
"created_at"
)
sortOrder
:=
c
.
DefaultQuery
(
"sort_order"
,
"desc"
)
subscriptions
,
pagination
,
err
:=
h
.
subscriptionService
.
List
(
c
.
Request
.
Context
(),
page
,
pageSize
,
userID
,
groupID
,
status
,
sortBy
,
sortOrder
)
if
err
!=
nil
{
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
}
}
out
:=
make
([]
dto
.
UserSubscription
,
0
,
len
(
subscriptions
))
out
:=
make
([]
dto
.
Admin
UserSubscription
,
0
,
len
(
subscriptions
))
for
i
:=
range
subscriptions
{
for
i
:=
range
subscriptions
{
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
(
&
subscriptions
[
i
]))
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
Admin
(
&
subscriptions
[
i
]))
}
}
response
.
PaginatedWithResult
(
c
,
out
,
toResponsePagination
(
pagination
))
response
.
PaginatedWithResult
(
c
,
out
,
toResponsePagination
(
pagination
))
}
}
...
@@ -105,7 +109,7 @@ func (h *SubscriptionHandler) GetByID(c *gin.Context) {
...
@@ -105,7 +109,7 @@ func (h *SubscriptionHandler) GetByID(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
(
subscription
))
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
Admin
(
subscription
))
}
}
// GetProgress handles getting subscription usage progress
// GetProgress handles getting subscription usage progress
...
@@ -150,7 +154,7 @@ func (h *SubscriptionHandler) Assign(c *gin.Context) {
...
@@ -150,7 +154,7 @@ func (h *SubscriptionHandler) Assign(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
(
subscription
))
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
Admin
(
subscription
))
}
}
// BulkAssign handles bulk assigning subscriptions to multiple users
// BulkAssign handles bulk assigning subscriptions to multiple users
...
@@ -180,7 +184,7 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) {
...
@@ -180,7 +184,7 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) {
response
.
Success
(
c
,
dto
.
BulkAssignResultFromService
(
result
))
response
.
Success
(
c
,
dto
.
BulkAssignResultFromService
(
result
))
}
}
// Extend handles
extend
ing a subscription
// Extend handles
adjust
ing a subscription
(extend or shorten)
// POST /api/v1/admin/subscriptions/:id/extend
// POST /api/v1/admin/subscriptions/:id/extend
func
(
h
*
SubscriptionHandler
)
Extend
(
c
*
gin
.
Context
)
{
func
(
h
*
SubscriptionHandler
)
Extend
(
c
*
gin
.
Context
)
{
subscriptionID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
subscriptionID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
...
@@ -189,7 +193,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
...
@@ -189,7 +193,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
return
return
}
}
var
req
Extend
SubscriptionRequest
var
req
Adjust
SubscriptionRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
return
...
@@ -201,7 +205,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
...
@@ -201,7 +205,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
(
subscription
))
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
Admin
(
subscription
))
}
}
// Revoke handles revoking a subscription
// Revoke handles revoking a subscription
...
@@ -239,9 +243,9 @@ func (h *SubscriptionHandler) ListByGroup(c *gin.Context) {
...
@@ -239,9 +243,9 @@ func (h *SubscriptionHandler) ListByGroup(c *gin.Context) {
return
return
}
}
out
:=
make
([]
dto
.
UserSubscription
,
0
,
len
(
subscriptions
))
out
:=
make
([]
dto
.
Admin
UserSubscription
,
0
,
len
(
subscriptions
))
for
i
:=
range
subscriptions
{
for
i
:=
range
subscriptions
{
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
(
&
subscriptions
[
i
]))
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
Admin
(
&
subscriptions
[
i
]))
}
}
response
.
PaginatedWithResult
(
c
,
out
,
toResponsePagination
(
pagination
))
response
.
PaginatedWithResult
(
c
,
out
,
toResponsePagination
(
pagination
))
}
}
...
@@ -261,9 +265,9 @@ func (h *SubscriptionHandler) ListByUser(c *gin.Context) {
...
@@ -261,9 +265,9 @@ func (h *SubscriptionHandler) ListByUser(c *gin.Context) {
return
return
}
}
out
:=
make
([]
dto
.
UserSubscription
,
0
,
len
(
subscriptions
))
out
:=
make
([]
dto
.
Admin
UserSubscription
,
0
,
len
(
subscriptions
))
for
i
:=
range
subscriptions
{
for
i
:=
range
subscriptions
{
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
(
&
subscriptions
[
i
]))
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
Admin
(
&
subscriptions
[
i
]))
}
}
response
.
Success
(
c
,
out
)
response
.
Success
(
c
,
out
)
}
}
...
...
backend/internal/handler/admin/usage_cleanup_handler_test.go
0 → 100644
View file @
2fe8932c
package
admin
import
(
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type
cleanupRepoStub
struct
{
mu
sync
.
Mutex
created
[]
*
service
.
UsageCleanupTask
listTasks
[]
service
.
UsageCleanupTask
listResult
*
pagination
.
PaginationResult
listErr
error
statusByID
map
[
int64
]
string
}
func
(
s
*
cleanupRepoStub
)
CreateTask
(
ctx
context
.
Context
,
task
*
service
.
UsageCleanupTask
)
error
{
if
task
==
nil
{
return
nil
}
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
if
task
.
ID
==
0
{
task
.
ID
=
int64
(
len
(
s
.
created
)
+
1
)
}
if
task
.
CreatedAt
.
IsZero
()
{
task
.
CreatedAt
=
time
.
Now
()
.
UTC
()
}
task
.
UpdatedAt
=
task
.
CreatedAt
clone
:=
*
task
s
.
created
=
append
(
s
.
created
,
&
clone
)
return
nil
}
func
(
s
*
cleanupRepoStub
)
ListTasks
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
UsageCleanupTask
,
*
pagination
.
PaginationResult
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
return
s
.
listTasks
,
s
.
listResult
,
s
.
listErr
}
func
(
s
*
cleanupRepoStub
)
ClaimNextPendingTask
(
ctx
context
.
Context
,
staleRunningAfterSeconds
int64
)
(
*
service
.
UsageCleanupTask
,
error
)
{
return
nil
,
nil
}
func
(
s
*
cleanupRepoStub
)
GetTaskStatus
(
ctx
context
.
Context
,
taskID
int64
)
(
string
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
if
s
.
statusByID
==
nil
{
return
""
,
sql
.
ErrNoRows
}
status
,
ok
:=
s
.
statusByID
[
taskID
]
if
!
ok
{
return
""
,
sql
.
ErrNoRows
}
return
status
,
nil
}
func
(
s
*
cleanupRepoStub
)
UpdateTaskProgress
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
)
error
{
return
nil
}
func
(
s
*
cleanupRepoStub
)
CancelTask
(
ctx
context
.
Context
,
taskID
int64
,
canceledBy
int64
)
(
bool
,
error
)
{
s
.
mu
.
Lock
()
defer
s
.
mu
.
Unlock
()
if
s
.
statusByID
==
nil
{
s
.
statusByID
=
map
[
int64
]
string
{}
}
status
:=
s
.
statusByID
[
taskID
]
if
status
!=
service
.
UsageCleanupStatusPending
&&
status
!=
service
.
UsageCleanupStatusRunning
{
return
false
,
nil
}
s
.
statusByID
[
taskID
]
=
service
.
UsageCleanupStatusCanceled
return
true
,
nil
}
func
(
s
*
cleanupRepoStub
)
MarkTaskSucceeded
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
)
error
{
return
nil
}
func
(
s
*
cleanupRepoStub
)
MarkTaskFailed
(
ctx
context
.
Context
,
taskID
int64
,
deletedRows
int64
,
errorMsg
string
)
error
{
return
nil
}
func
(
s
*
cleanupRepoStub
)
DeleteUsageLogsBatch
(
ctx
context
.
Context
,
filters
service
.
UsageCleanupFilters
,
limit
int
)
(
int64
,
error
)
{
return
0
,
nil
}
var
_
service
.
UsageCleanupRepository
=
(
*
cleanupRepoStub
)(
nil
)
func
setupCleanupRouter
(
cleanupService
*
service
.
UsageCleanupService
,
userID
int64
)
*
gin
.
Engine
{
gin
.
SetMode
(
gin
.
TestMode
)
router
:=
gin
.
New
()
if
userID
>
0
{
router
.
Use
(
func
(
c
*
gin
.
Context
)
{
c
.
Set
(
string
(
middleware
.
ContextKeyUser
),
middleware
.
AuthSubject
{
UserID
:
userID
})
c
.
Next
()
})
}
handler
:=
NewUsageHandler
(
nil
,
nil
,
nil
,
cleanupService
)
router
.
POST
(
"/api/v1/admin/usage/cleanup-tasks"
,
handler
.
CreateCleanupTask
)
router
.
GET
(
"/api/v1/admin/usage/cleanup-tasks"
,
handler
.
ListCleanupTasks
)
router
.
POST
(
"/api/v1/admin/usage/cleanup-tasks/:id/cancel"
,
handler
.
CancelCleanupTask
)
return
router
}
func
TestUsageHandlerCreateCleanupTaskUnauthorized
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
0
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewBufferString
(
`{}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusUnauthorized
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskUnavailable
(
t
*
testing
.
T
)
{
router
:=
setupCleanupRouter
(
nil
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewBufferString
(
`{}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusServiceUnavailable
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskBindError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
88
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewBufferString
(
"{bad-json"
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskMissingRange
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
88
)
payload
:=
map
[
string
]
any
{
"start_date"
:
"2024-01-01"
,
"timezone"
:
"UTC"
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskInvalidDate
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
88
)
payload
:=
map
[
string
]
any
{
"start_date"
:
"2024-13-01"
,
"end_date"
:
"2024-01-02"
,
"timezone"
:
"UTC"
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskInvalidEndDate
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
88
)
payload
:=
map
[
string
]
any
{
"start_date"
:
"2024-01-01"
,
"end_date"
:
"2024-02-40"
,
"timezone"
:
"UTC"
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusBadRequest
,
recorder
.
Code
)
}
func
TestUsageHandlerCreateCleanupTaskSuccess
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
99
)
payload
:=
map
[
string
]
any
{
"start_date"
:
" 2024-01-01 "
,
"end_date"
:
"2024-01-02"
,
"timezone"
:
"UTC"
,
"model"
:
"gpt-4"
,
}
body
,
err
:=
json
.
Marshal
(
payload
)
require
.
NoError
(
t
,
err
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks"
,
bytes
.
NewReader
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
var
resp
response
.
Response
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
repo
.
mu
.
Lock
()
defer
repo
.
mu
.
Unlock
()
require
.
Len
(
t
,
repo
.
created
,
1
)
created
:=
repo
.
created
[
0
]
require
.
Equal
(
t
,
int64
(
99
),
created
.
CreatedBy
)
require
.
NotNil
(
t
,
created
.
Filters
.
Model
)
require
.
Equal
(
t
,
"gpt-4"
,
*
created
.
Filters
.
Model
)
start
:=
time
.
Date
(
2024
,
1
,
1
,
0
,
0
,
0
,
0
,
time
.
UTC
)
end
:=
time
.
Date
(
2024
,
1
,
2
,
0
,
0
,
0
,
0
,
time
.
UTC
)
.
Add
(
24
*
time
.
Hour
-
time
.
Nanosecond
)
require
.
True
(
t
,
created
.
Filters
.
StartTime
.
Equal
(
start
))
require
.
True
(
t
,
created
.
Filters
.
EndTime
.
Equal
(
end
))
}
func
TestUsageHandlerListCleanupTasksUnavailable
(
t
*
testing
.
T
)
{
router
:=
setupCleanupRouter
(
nil
,
0
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/usage/cleanup-tasks"
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusServiceUnavailable
,
recorder
.
Code
)
}
func
TestUsageHandlerListCleanupTasksSuccess
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
repo
.
listTasks
=
[]
service
.
UsageCleanupTask
{
{
ID
:
7
,
Status
:
service
.
UsageCleanupStatusSucceeded
,
CreatedBy
:
4
,
},
}
repo
.
listResult
=
&
pagination
.
PaginationResult
{
Total
:
1
,
Page
:
1
,
PageSize
:
20
,
Pages
:
1
}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/usage/cleanup-tasks"
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
recorder
.
Code
)
var
resp
struct
{
Code
int
`json:"code"`
Data
struct
{
Items
[]
dto
.
UsageCleanupTask
`json:"items"`
Total
int64
`json:"total"`
Page
int
`json:"page"`
}
`json:"data"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
recorder
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Len
(
t
,
resp
.
Data
.
Items
,
1
)
require
.
Equal
(
t
,
int64
(
7
),
resp
.
Data
.
Items
[
0
]
.
ID
)
require
.
Equal
(
t
,
int64
(
1
),
resp
.
Data
.
Total
)
require
.
Equal
(
t
,
1
,
resp
.
Data
.
Page
)
}
func
TestUsageHandlerListCleanupTasksError
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
listErr
:
errors
.
New
(
"boom"
)}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
,
MaxRangeDays
:
31
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/usage/cleanup-tasks"
,
nil
)
recorder
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
recorder
,
req
)
require
.
Equal
(
t
,
http
.
StatusInternalServerError
,
recorder
.
Code
)
}
func
TestUsageHandlerCancelCleanupTaskUnauthorized
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
0
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks/1/cancel"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusUnauthorized
,
rec
.
Code
)
}
func
TestUsageHandlerCancelCleanupTaskNotFound
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks/999/cancel"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusNotFound
,
rec
.
Code
)
}
func
TestUsageHandlerCancelCleanupTaskConflict
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusByID
:
map
[
int64
]
string
{
2
:
service
.
UsageCleanupStatusSucceeded
}}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks/2/cancel"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusConflict
,
rec
.
Code
)
}
func
TestUsageHandlerCancelCleanupTaskSuccess
(
t
*
testing
.
T
)
{
repo
:=
&
cleanupRepoStub
{
statusByID
:
map
[
int64
]
string
{
3
:
service
.
UsageCleanupStatusPending
}}
cfg
:=
&
config
.
Config
{
UsageCleanup
:
config
.
UsageCleanupConfig
{
Enabled
:
true
}}
cleanupService
:=
service
.
NewUsageCleanupService
(
repo
,
nil
,
nil
,
cfg
)
router
:=
setupCleanupRouter
(
cleanupService
,
1
)
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/api/v1/admin/usage/cleanup-tasks/3/cancel"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
}
backend/internal/handler/admin/usage_handler.go
View file @
2fe8932c
package
admin
package
admin
import
(
import
(
"log"
"net/http"
"strconv"
"strconv"
"strings"
"time"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
...
@@ -9,6 +12,7 @@ import (
...
@@ -9,6 +12,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
...
@@ -16,9 +20,10 @@ import (
...
@@ -16,9 +20,10 @@ import (
// UsageHandler handles admin usage-related requests
// UsageHandler handles admin usage-related requests
type
UsageHandler
struct
{
type
UsageHandler
struct
{
usageService
*
service
.
UsageService
usageService
*
service
.
UsageService
apiKeyService
*
service
.
APIKeyService
apiKeyService
*
service
.
APIKeyService
adminService
service
.
AdminService
adminService
service
.
AdminService
cleanupService
*
service
.
UsageCleanupService
}
}
// NewUsageHandler creates a new admin usage handler
// NewUsageHandler creates a new admin usage handler
...
@@ -26,14 +31,30 @@ func NewUsageHandler(
...
@@ -26,14 +31,30 @@ func NewUsageHandler(
usageService
*
service
.
UsageService
,
usageService
*
service
.
UsageService
,
apiKeyService
*
service
.
APIKeyService
,
apiKeyService
*
service
.
APIKeyService
,
adminService
service
.
AdminService
,
adminService
service
.
AdminService
,
cleanupService
*
service
.
UsageCleanupService
,
)
*
UsageHandler
{
)
*
UsageHandler
{
return
&
UsageHandler
{
return
&
UsageHandler
{
usageService
:
usageService
,
usageService
:
usageService
,
apiKeyService
:
apiKeyService
,
apiKeyService
:
apiKeyService
,
adminService
:
adminService
,
adminService
:
adminService
,
cleanupService
:
cleanupService
,
}
}
}
}
// CreateUsageCleanupTaskRequest represents cleanup task creation request
type
CreateUsageCleanupTaskRequest
struct
{
StartDate
string
`json:"start_date"`
EndDate
string
`json:"end_date"`
UserID
*
int64
`json:"user_id"`
APIKeyID
*
int64
`json:"api_key_id"`
AccountID
*
int64
`json:"account_id"`
GroupID
*
int64
`json:"group_id"`
Model
*
string
`json:"model"`
Stream
*
bool
`json:"stream"`
BillingType
*
int8
`json:"billing_type"`
Timezone
string
`json:"timezone"`
}
// List handles listing all usage records with filters
// List handles listing all usage records with filters
// GET /api/v1/admin/usage
// GET /api/v1/admin/usage
func
(
h
*
UsageHandler
)
List
(
c
*
gin
.
Context
)
{
func
(
h
*
UsageHandler
)
List
(
c
*
gin
.
Context
)
{
...
@@ -142,7 +163,7 @@ func (h *UsageHandler) List(c *gin.Context) {
...
@@ -142,7 +163,7 @@ func (h *UsageHandler) List(c *gin.Context) {
return
return
}
}
out
:=
make
([]
dto
.
UsageLog
,
0
,
len
(
records
))
out
:=
make
([]
dto
.
Admin
UsageLog
,
0
,
len
(
records
))
for
i
:=
range
records
{
for
i
:=
range
records
{
out
=
append
(
out
,
*
dto
.
UsageLogFromServiceAdmin
(
&
records
[
i
]))
out
=
append
(
out
,
*
dto
.
UsageLogFromServiceAdmin
(
&
records
[
i
]))
}
}
...
@@ -344,3 +365,162 @@ func (h *UsageHandler) SearchAPIKeys(c *gin.Context) {
...
@@ -344,3 +365,162 @@ func (h *UsageHandler) SearchAPIKeys(c *gin.Context) {
response
.
Success
(
c
,
result
)
response
.
Success
(
c
,
result
)
}
}
// ListCleanupTasks handles listing usage cleanup tasks
// GET /api/v1/admin/usage/cleanup-tasks
func
(
h
*
UsageHandler
)
ListCleanupTasks
(
c
*
gin
.
Context
)
{
if
h
.
cleanupService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Usage cleanup service unavailable"
)
return
}
operator
:=
int64
(
0
)
if
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
);
ok
{
operator
=
subject
.
UserID
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
log
.
Printf
(
"[UsageCleanup] 请求清理任务列表: operator=%d page=%d page_size=%d"
,
operator
,
page
,
pageSize
)
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
tasks
,
result
,
err
:=
h
.
cleanupService
.
ListTasks
(
c
.
Request
.
Context
(),
params
)
if
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] 查询清理任务列表失败: operator=%d page=%d page_size=%d err=%v"
,
operator
,
page
,
pageSize
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
}
out
:=
make
([]
dto
.
UsageCleanupTask
,
0
,
len
(
tasks
))
for
i
:=
range
tasks
{
out
=
append
(
out
,
*
dto
.
UsageCleanupTaskFromService
(
&
tasks
[
i
]))
}
log
.
Printf
(
"[UsageCleanup] 返回清理任务列表: operator=%d total=%d items=%d page=%d page_size=%d"
,
operator
,
result
.
Total
,
len
(
out
),
page
,
pageSize
)
response
.
Paginated
(
c
,
out
,
result
.
Total
,
page
,
pageSize
)
}
// CreateCleanupTask handles creating a usage cleanup task
// POST /api/v1/admin/usage/cleanup-tasks
func
(
h
*
UsageHandler
)
CreateCleanupTask
(
c
*
gin
.
Context
)
{
if
h
.
cleanupService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Usage cleanup service unavailable"
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Unauthorized
(
c
,
"Unauthorized"
)
return
}
var
req
CreateUsageCleanupTaskRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
req
.
StartDate
=
strings
.
TrimSpace
(
req
.
StartDate
)
req
.
EndDate
=
strings
.
TrimSpace
(
req
.
EndDate
)
if
req
.
StartDate
==
""
||
req
.
EndDate
==
""
{
response
.
BadRequest
(
c
,
"start_date and end_date are required"
)
return
}
startTime
,
err
:=
timezone
.
ParseInUserLocation
(
"2006-01-02"
,
req
.
StartDate
,
req
.
Timezone
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid start_date format, use YYYY-MM-DD"
)
return
}
endTime
,
err
:=
timezone
.
ParseInUserLocation
(
"2006-01-02"
,
req
.
EndDate
,
req
.
Timezone
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid end_date format, use YYYY-MM-DD"
)
return
}
endTime
=
endTime
.
Add
(
24
*
time
.
Hour
-
time
.
Nanosecond
)
filters
:=
service
.
UsageCleanupFilters
{
StartTime
:
startTime
,
EndTime
:
endTime
,
UserID
:
req
.
UserID
,
APIKeyID
:
req
.
APIKeyID
,
AccountID
:
req
.
AccountID
,
GroupID
:
req
.
GroupID
,
Model
:
req
.
Model
,
Stream
:
req
.
Stream
,
BillingType
:
req
.
BillingType
,
}
var
userID
any
if
filters
.
UserID
!=
nil
{
userID
=
*
filters
.
UserID
}
var
apiKeyID
any
if
filters
.
APIKeyID
!=
nil
{
apiKeyID
=
*
filters
.
APIKeyID
}
var
accountID
any
if
filters
.
AccountID
!=
nil
{
accountID
=
*
filters
.
AccountID
}
var
groupID
any
if
filters
.
GroupID
!=
nil
{
groupID
=
*
filters
.
GroupID
}
var
model
any
if
filters
.
Model
!=
nil
{
model
=
*
filters
.
Model
}
var
stream
any
if
filters
.
Stream
!=
nil
{
stream
=
*
filters
.
Stream
}
var
billingType
any
if
filters
.
BillingType
!=
nil
{
billingType
=
*
filters
.
BillingType
}
log
.
Printf
(
"[UsageCleanup] 请求创建清理任务: operator=%d start=%s end=%s user_id=%v api_key_id=%v account_id=%v group_id=%v model=%v stream=%v billing_type=%v tz=%q"
,
subject
.
UserID
,
filters
.
StartTime
.
Format
(
time
.
RFC3339
),
filters
.
EndTime
.
Format
(
time
.
RFC3339
),
userID
,
apiKeyID
,
accountID
,
groupID
,
model
,
stream
,
billingType
,
req
.
Timezone
,
)
task
,
err
:=
h
.
cleanupService
.
CreateTask
(
c
.
Request
.
Context
(),
filters
,
subject
.
UserID
)
if
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] 创建清理任务失败: operator=%d err=%v"
,
subject
.
UserID
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
}
log
.
Printf
(
"[UsageCleanup] 清理任务已创建: task=%d operator=%d status=%s"
,
task
.
ID
,
subject
.
UserID
,
task
.
Status
)
response
.
Success
(
c
,
dto
.
UsageCleanupTaskFromService
(
task
))
}
// CancelCleanupTask handles canceling a usage cleanup task
// POST /api/v1/admin/usage/cleanup-tasks/:id/cancel
func
(
h
*
UsageHandler
)
CancelCleanupTask
(
c
*
gin
.
Context
)
{
if
h
.
cleanupService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Usage cleanup service unavailable"
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Unauthorized
(
c
,
"Unauthorized"
)
return
}
idStr
:=
strings
.
TrimSpace
(
c
.
Param
(
"id"
))
taskID
,
err
:=
strconv
.
ParseInt
(
idStr
,
10
,
64
)
if
err
!=
nil
||
taskID
<=
0
{
response
.
BadRequest
(
c
,
"Invalid task id"
)
return
}
log
.
Printf
(
"[UsageCleanup] 请求取消清理任务: task=%d operator=%d"
,
taskID
,
subject
.
UserID
)
if
err
:=
h
.
cleanupService
.
CancelTask
(
c
.
Request
.
Context
(),
taskID
,
subject
.
UserID
);
err
!=
nil
{
log
.
Printf
(
"[UsageCleanup] 取消清理任务失败: task=%d operator=%d err=%v"
,
taskID
,
subject
.
UserID
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
}
log
.
Printf
(
"[UsageCleanup] 清理任务已取消: task=%d operator=%d"
,
taskID
,
subject
.
UserID
)
response
.
Success
(
c
,
gin
.
H
{
"id"
:
taskID
,
"status"
:
service
.
UsageCleanupStatusCanceled
})
}
backend/internal/handler/admin/user_handler.go
View file @
2fe8932c
...
@@ -84,9 +84,9 @@ func (h *UserHandler) List(c *gin.Context) {
...
@@ -84,9 +84,9 @@ func (h *UserHandler) List(c *gin.Context) {
return
return
}
}
out
:=
make
([]
dto
.
User
,
0
,
len
(
users
))
out
:=
make
([]
dto
.
Admin
User
,
0
,
len
(
users
))
for
i
:=
range
users
{
for
i
:=
range
users
{
out
=
append
(
out
,
*
dto
.
UserFromService
(
&
users
[
i
]))
out
=
append
(
out
,
*
dto
.
UserFromService
Admin
(
&
users
[
i
]))
}
}
response
.
Paginated
(
c
,
out
,
total
,
page
,
pageSize
)
response
.
Paginated
(
c
,
out
,
total
,
page
,
pageSize
)
}
}
...
@@ -129,7 +129,7 @@ func (h *UserHandler) GetByID(c *gin.Context) {
...
@@ -129,7 +129,7 @@ func (h *UserHandler) GetByID(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
}
// Create handles creating a new user
// Create handles creating a new user
...
@@ -155,7 +155,7 @@ func (h *UserHandler) Create(c *gin.Context) {
...
@@ -155,7 +155,7 @@ func (h *UserHandler) Create(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
}
// Update handles updating a user
// Update handles updating a user
...
@@ -189,7 +189,7 @@ func (h *UserHandler) Update(c *gin.Context) {
...
@@ -189,7 +189,7 @@ func (h *UserHandler) Update(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
}
// Delete handles deleting a user
// Delete handles deleting a user
...
@@ -231,7 +231,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) {
...
@@ -231,7 +231,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) {
return
return
}
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
}
// GetUserAPIKeys handles getting user's API keys
// GetUserAPIKeys handles getting user's API keys
...
...
backend/internal/handler/auth_handler.go
View file @
2fe8932c
package
handler
package
handler
import
(
import
(
"log/slog"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
...
@@ -18,16 +20,18 @@ type AuthHandler struct {
...
@@ -18,16 +20,18 @@ type AuthHandler struct {
userService
*
service
.
UserService
userService
*
service
.
UserService
settingSvc
*
service
.
SettingService
settingSvc
*
service
.
SettingService
promoService
*
service
.
PromoService
promoService
*
service
.
PromoService
totpService
*
service
.
TotpService
}
}
// NewAuthHandler creates a new AuthHandler
// 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
,
totpService
*
service
.
TotpService
)
*
AuthHandler
{
return
&
AuthHandler
{
return
&
AuthHandler
{
cfg
:
cfg
,
cfg
:
cfg
,
authService
:
authService
,
authService
:
authService
,
userService
:
userService
,
userService
:
userService
,
settingSvc
:
settingService
,
settingSvc
:
settingService
,
promoService
:
promoService
,
promoService
:
promoService
,
totpService
:
totpService
,
}
}
}
}
...
@@ -144,6 +148,100 @@ func (h *AuthHandler) Login(c *gin.Context) {
...
@@ -144,6 +148,100 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
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
{
response
.
Success
(
c
,
AuthResponse
{
AccessToken
:
token
,
AccessToken
:
token
,
TokenType
:
"Bearer"
,
TokenType
:
"Bearer"
,
...
@@ -195,6 +293,15 @@ type ValidatePromoCodeResponse struct {
...
@@ -195,6 +293,15 @@ type ValidatePromoCodeResponse struct {
// ValidatePromoCode 验证优惠码(公开接口,注册前调用)
// ValidatePromoCode 验证优惠码(公开接口,注册前调用)
// POST /api/v1/auth/validate-promo-code
// POST /api/v1/auth/validate-promo-code
func
(
h
*
AuthHandler
)
ValidatePromoCode
(
c
*
gin
.
Context
)
{
func
(
h
*
AuthHandler
)
ValidatePromoCode
(
c
*
gin
.
Context
)
{
// 检查优惠码功能是否启用
if
h
.
settingSvc
!=
nil
&&
!
h
.
settingSvc
.
IsPromoCodeEnabled
(
c
.
Request
.
Context
())
{
response
.
Success
(
c
,
ValidatePromoCodeResponse
{
Valid
:
false
,
ErrorCode
:
"PROMO_CODE_DISABLED"
,
})
return
}
var
req
ValidatePromoCodeRequest
var
req
ValidatePromoCodeRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
...
@@ -238,3 +345,85 @@ func (h *AuthHandler) ValidatePromoCode(c *gin.Context) {
...
@@ -238,3 +345,85 @@ func (h *AuthHandler) ValidatePromoCode(c *gin.Context) {
BonusAmount
:
promoCode
.
BonusAmount
,
BonusAmount
:
promoCode
.
BonusAmount
,
})
})
}
}
// 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/mappers.go
View file @
2fe8932c
...
@@ -15,7 +15,6 @@ func UserFromServiceShallow(u *service.User) *User {
...
@@ -15,7 +15,6 @@ func UserFromServiceShallow(u *service.User) *User {
ID
:
u
.
ID
,
ID
:
u
.
ID
,
Email
:
u
.
Email
,
Email
:
u
.
Email
,
Username
:
u
.
Username
,
Username
:
u
.
Username
,
Notes
:
u
.
Notes
,
Role
:
u
.
Role
,
Role
:
u
.
Role
,
Balance
:
u
.
Balance
,
Balance
:
u
.
Balance
,
Concurrency
:
u
.
Concurrency
,
Concurrency
:
u
.
Concurrency
,
...
@@ -48,6 +47,22 @@ func UserFromService(u *service.User) *User {
...
@@ -48,6 +47,22 @@ func UserFromService(u *service.User) *User {
return
out
return
out
}
}
// UserFromServiceAdmin converts a service User to DTO for admin users.
// It includes notes - user-facing endpoints must not use this.
func
UserFromServiceAdmin
(
u
*
service
.
User
)
*
AdminUser
{
if
u
==
nil
{
return
nil
}
base
:=
UserFromService
(
u
)
if
base
==
nil
{
return
nil
}
return
&
AdminUser
{
User
:
*
base
,
Notes
:
u
.
Notes
,
}
}
func
APIKeyFromService
(
k
*
service
.
APIKey
)
*
APIKey
{
func
APIKeyFromService
(
k
*
service
.
APIKey
)
*
APIKey
{
if
k
==
nil
{
if
k
==
nil
{
return
nil
return
nil
...
@@ -72,36 +87,29 @@ func GroupFromServiceShallow(g *service.Group) *Group {
...
@@ -72,36 +87,29 @@ func GroupFromServiceShallow(g *service.Group) *Group {
if
g
==
nil
{
if
g
==
nil
{
return
nil
return
nil
}
}
return
&
Group
{
out
:=
groupFromServiceBase
(
g
)
ID
:
g
.
ID
,
return
&
out
Name
:
g
.
Name
,
Description
:
g
.
Description
,
Platform
:
g
.
Platform
,
RateMultiplier
:
g
.
RateMultiplier
,
IsExclusive
:
g
.
IsExclusive
,
Status
:
g
.
Status
,
SubscriptionType
:
g
.
SubscriptionType
,
DailyLimitUSD
:
g
.
DailyLimitUSD
,
WeeklyLimitUSD
:
g
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
g
.
MonthlyLimitUSD
,
ImagePrice1K
:
g
.
ImagePrice1K
,
ImagePrice2K
:
g
.
ImagePrice2K
,
ImagePrice4K
:
g
.
ImagePrice4K
,
ClaudeCodeOnly
:
g
.
ClaudeCodeOnly
,
FallbackGroupID
:
g
.
FallbackGroupID
,
ModelRouting
:
g
.
ModelRouting
,
ModelRoutingEnabled
:
g
.
ModelRoutingEnabled
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
AccountCount
:
g
.
AccountCount
,
}
}
}
func
GroupFromService
(
g
*
service
.
Group
)
*
Group
{
func
GroupFromService
(
g
*
service
.
Group
)
*
Group
{
if
g
==
nil
{
if
g
==
nil
{
return
nil
return
nil
}
}
out
:=
GroupFromServiceShallow
(
g
)
return
GroupFromServiceShallow
(
g
)
}
// GroupFromServiceAdmin converts a service Group to DTO for admin users.
// It includes internal fields like model_routing and account_count.
func
GroupFromServiceAdmin
(
g
*
service
.
Group
)
*
AdminGroup
{
if
g
==
nil
{
return
nil
}
out
:=
&
AdminGroup
{
Group
:
groupFromServiceBase
(
g
),
ModelRouting
:
g
.
ModelRouting
,
ModelRoutingEnabled
:
g
.
ModelRoutingEnabled
,
AccountCount
:
g
.
AccountCount
,
}
if
len
(
g
.
AccountGroups
)
>
0
{
if
len
(
g
.
AccountGroups
)
>
0
{
out
.
AccountGroups
=
make
([]
AccountGroup
,
0
,
len
(
g
.
AccountGroups
))
out
.
AccountGroups
=
make
([]
AccountGroup
,
0
,
len
(
g
.
AccountGroups
))
for
i
:=
range
g
.
AccountGroups
{
for
i
:=
range
g
.
AccountGroups
{
...
@@ -112,6 +120,29 @@ func GroupFromService(g *service.Group) *Group {
...
@@ -112,6 +120,29 @@ func GroupFromService(g *service.Group) *Group {
return
out
return
out
}
}
func
groupFromServiceBase
(
g
*
service
.
Group
)
Group
{
return
Group
{
ID
:
g
.
ID
,
Name
:
g
.
Name
,
Description
:
g
.
Description
,
Platform
:
g
.
Platform
,
RateMultiplier
:
g
.
RateMultiplier
,
IsExclusive
:
g
.
IsExclusive
,
Status
:
g
.
Status
,
SubscriptionType
:
g
.
SubscriptionType
,
DailyLimitUSD
:
g
.
DailyLimitUSD
,
WeeklyLimitUSD
:
g
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
g
.
MonthlyLimitUSD
,
ImagePrice1K
:
g
.
ImagePrice1K
,
ImagePrice2K
:
g
.
ImagePrice2K
,
ImagePrice4K
:
g
.
ImagePrice4K
,
ClaudeCodeOnly
:
g
.
ClaudeCodeOnly
,
FallbackGroupID
:
g
.
FallbackGroupID
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
}
}
func
AccountFromServiceShallow
(
a
*
service
.
Account
)
*
Account
{
func
AccountFromServiceShallow
(
a
*
service
.
Account
)
*
Account
{
if
a
==
nil
{
if
a
==
nil
{
return
nil
return
nil
...
@@ -161,6 +192,16 @@ func AccountFromServiceShallow(a *service.Account) *Account {
...
@@ -161,6 +192,16 @@ func AccountFromServiceShallow(a *service.Account) *Account {
if
idleTimeout
:=
a
.
GetSessionIdleTimeoutMinutes
();
idleTimeout
>
0
{
if
idleTimeout
:=
a
.
GetSessionIdleTimeoutMinutes
();
idleTimeout
>
0
{
out
.
SessionIdleTimeoutMin
=
&
idleTimeout
out
.
SessionIdleTimeoutMin
=
&
idleTimeout
}
}
// TLS指纹伪装开关
if
a
.
IsTLSFingerprintEnabled
()
{
enabled
:=
true
out
.
EnableTLSFingerprint
=
&
enabled
}
// 会话ID伪装开关
if
a
.
IsSessionIDMaskingEnabled
()
{
enabled
:=
true
out
.
EnableSessionIDMasking
=
&
enabled
}
}
}
return
out
return
out
...
@@ -263,7 +304,24 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode {
...
@@ -263,7 +304,24 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode {
if
rc
==
nil
{
if
rc
==
nil
{
return
nil
return
nil
}
}
return
&
RedeemCode
{
out
:=
redeemCodeFromServiceBase
(
rc
)
return
&
out
}
// RedeemCodeFromServiceAdmin converts a service RedeemCode to DTO for admin users.
// It includes notes - user-facing endpoints must not use this.
func
RedeemCodeFromServiceAdmin
(
rc
*
service
.
RedeemCode
)
*
AdminRedeemCode
{
if
rc
==
nil
{
return
nil
}
return
&
AdminRedeemCode
{
RedeemCode
:
redeemCodeFromServiceBase
(
rc
),
Notes
:
rc
.
Notes
,
}
}
func
redeemCodeFromServiceBase
(
rc
*
service
.
RedeemCode
)
RedeemCode
{
return
RedeemCode
{
ID
:
rc
.
ID
,
ID
:
rc
.
ID
,
Code
:
rc
.
Code
,
Code
:
rc
.
Code
,
Type
:
rc
.
Type
,
Type
:
rc
.
Type
,
...
@@ -271,7 +329,6 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode {
...
@@ -271,7 +329,6 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode {
Status
:
rc
.
Status
,
Status
:
rc
.
Status
,
UsedBy
:
rc
.
UsedBy
,
UsedBy
:
rc
.
UsedBy
,
UsedAt
:
rc
.
UsedAt
,
UsedAt
:
rc
.
UsedAt
,
Notes
:
rc
.
Notes
,
CreatedAt
:
rc
.
CreatedAt
,
CreatedAt
:
rc
.
CreatedAt
,
GroupID
:
rc
.
GroupID
,
GroupID
:
rc
.
GroupID
,
ValidityDays
:
rc
.
ValidityDays
,
ValidityDays
:
rc
.
ValidityDays
,
...
@@ -292,14 +349,9 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary {
...
@@ -292,14 +349,9 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary {
}
}
}
}
// usageLogFromServiceBase is a helper that converts service UsageLog to DTO.
func
usageLogFromServiceUser
(
l
*
service
.
UsageLog
)
UsageLog
{
// The account parameter allows caller to control what Account info is included.
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。
// The includeIPAddress parameter controls whether to include the IP address (admin-only).
return
UsageLog
{
func
usageLogFromServiceBase
(
l
*
service
.
UsageLog
,
account
*
AccountSummary
,
includeIPAddress
bool
)
*
UsageLog
{
if
l
==
nil
{
return
nil
}
result
:=
&
UsageLog
{
ID
:
l
.
ID
,
ID
:
l
.
ID
,
UserID
:
l
.
UserID
,
UserID
:
l
.
UserID
,
APIKeyID
:
l
.
APIKeyID
,
APIKeyID
:
l
.
APIKeyID
,
...
@@ -321,7 +373,6 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
...
@@ -321,7 +373,6 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
TotalCost
:
l
.
TotalCost
,
TotalCost
:
l
.
TotalCost
,
ActualCost
:
l
.
ActualCost
,
ActualCost
:
l
.
ActualCost
,
RateMultiplier
:
l
.
RateMultiplier
,
RateMultiplier
:
l
.
RateMultiplier
,
AccountRateMultiplier
:
l
.
AccountRateMultiplier
,
BillingType
:
l
.
BillingType
,
BillingType
:
l
.
BillingType
,
Stream
:
l
.
Stream
,
Stream
:
l
.
Stream
,
DurationMs
:
l
.
DurationMs
,
DurationMs
:
l
.
DurationMs
,
...
@@ -332,30 +383,63 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
...
@@ -332,30 +383,63 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
CreatedAt
:
l
.
CreatedAt
,
CreatedAt
:
l
.
CreatedAt
,
User
:
UserFromServiceShallow
(
l
.
User
),
User
:
UserFromServiceShallow
(
l
.
User
),
APIKey
:
APIKeyFromService
(
l
.
APIKey
),
APIKey
:
APIKeyFromService
(
l
.
APIKey
),
Account
:
account
,
Group
:
GroupFromServiceShallow
(
l
.
Group
),
Group
:
GroupFromServiceShallow
(
l
.
Group
),
Subscription
:
UserSubscriptionFromService
(
l
.
Subscription
),
Subscription
:
UserSubscriptionFromService
(
l
.
Subscription
),
}
}
// IP 地址仅对管理员可见
if
includeIPAddress
{
result
.
IPAddress
=
l
.
IPAddress
}
return
result
}
}
// UsageLogFromService converts a service UsageLog to DTO for regular users.
// UsageLogFromService converts a service UsageLog to DTO for regular users.
// It excludes Account details and IP address - users should not see these.
// It excludes Account details and IP address - users should not see these.
func
UsageLogFromService
(
l
*
service
.
UsageLog
)
*
UsageLog
{
func
UsageLogFromService
(
l
*
service
.
UsageLog
)
*
UsageLog
{
return
usageLogFromServiceBase
(
l
,
nil
,
false
)
if
l
==
nil
{
return
nil
}
u
:=
usageLogFromServiceUser
(
l
)
return
&
u
}
}
// UsageLogFromServiceAdmin converts a service UsageLog to DTO for admin users.
// UsageLogFromServiceAdmin converts a service UsageLog to DTO for admin users.
// It includes minimal Account info (ID, Name only) and IP address.
// It includes minimal Account info (ID, Name only) and IP address.
func
UsageLogFromServiceAdmin
(
l
*
service
.
UsageLog
)
*
UsageLog
{
func
UsageLogFromServiceAdmin
(
l
*
service
.
UsageLog
)
*
Admin
UsageLog
{
if
l
==
nil
{
if
l
==
nil
{
return
nil
return
nil
}
}
return
usageLogFromServiceBase
(
l
,
AccountSummaryFromService
(
l
.
Account
),
true
)
return
&
AdminUsageLog
{
UsageLog
:
usageLogFromServiceUser
(
l
),
AccountRateMultiplier
:
l
.
AccountRateMultiplier
,
IPAddress
:
l
.
IPAddress
,
Account
:
AccountSummaryFromService
(
l
.
Account
),
}
}
func
UsageCleanupTaskFromService
(
task
*
service
.
UsageCleanupTask
)
*
UsageCleanupTask
{
if
task
==
nil
{
return
nil
}
return
&
UsageCleanupTask
{
ID
:
task
.
ID
,
Status
:
task
.
Status
,
Filters
:
UsageCleanupFilters
{
StartTime
:
task
.
Filters
.
StartTime
,
EndTime
:
task
.
Filters
.
EndTime
,
UserID
:
task
.
Filters
.
UserID
,
APIKeyID
:
task
.
Filters
.
APIKeyID
,
AccountID
:
task
.
Filters
.
AccountID
,
GroupID
:
task
.
Filters
.
GroupID
,
Model
:
task
.
Filters
.
Model
,
Stream
:
task
.
Filters
.
Stream
,
BillingType
:
task
.
Filters
.
BillingType
,
},
CreatedBy
:
task
.
CreatedBy
,
DeletedRows
:
task
.
DeletedRows
,
ErrorMessage
:
task
.
ErrorMsg
,
CanceledBy
:
task
.
CanceledBy
,
CanceledAt
:
task
.
CanceledAt
,
StartedAt
:
task
.
StartedAt
,
FinishedAt
:
task
.
FinishedAt
,
CreatedAt
:
task
.
CreatedAt
,
UpdatedAt
:
task
.
UpdatedAt
,
}
}
}
func
SettingFromService
(
s
*
service
.
Setting
)
*
Setting
{
func
SettingFromService
(
s
*
service
.
Setting
)
*
Setting
{
...
@@ -374,7 +458,27 @@ func UserSubscriptionFromService(sub *service.UserSubscription) *UserSubscriptio
...
@@ -374,7 +458,27 @@ func UserSubscriptionFromService(sub *service.UserSubscription) *UserSubscriptio
if
sub
==
nil
{
if
sub
==
nil
{
return
nil
return
nil
}
}
return
&
UserSubscription
{
out
:=
userSubscriptionFromServiceBase
(
sub
)
return
&
out
}
// UserSubscriptionFromServiceAdmin converts a service UserSubscription to DTO for admin users.
// It includes assignment metadata and notes.
func
UserSubscriptionFromServiceAdmin
(
sub
*
service
.
UserSubscription
)
*
AdminUserSubscription
{
if
sub
==
nil
{
return
nil
}
return
&
AdminUserSubscription
{
UserSubscription
:
userSubscriptionFromServiceBase
(
sub
),
AssignedBy
:
sub
.
AssignedBy
,
AssignedAt
:
sub
.
AssignedAt
,
Notes
:
sub
.
Notes
,
AssignedByUser
:
UserFromServiceShallow
(
sub
.
AssignedByUser
),
}
}
func
userSubscriptionFromServiceBase
(
sub
*
service
.
UserSubscription
)
UserSubscription
{
return
UserSubscription
{
ID
:
sub
.
ID
,
ID
:
sub
.
ID
,
UserID
:
sub
.
UserID
,
UserID
:
sub
.
UserID
,
GroupID
:
sub
.
GroupID
,
GroupID
:
sub
.
GroupID
,
...
@@ -387,14 +491,10 @@ func UserSubscriptionFromService(sub *service.UserSubscription) *UserSubscriptio
...
@@ -387,14 +491,10 @@ func UserSubscriptionFromService(sub *service.UserSubscription) *UserSubscriptio
DailyUsageUSD
:
sub
.
DailyUsageUSD
,
DailyUsageUSD
:
sub
.
DailyUsageUSD
,
WeeklyUsageUSD
:
sub
.
WeeklyUsageUSD
,
WeeklyUsageUSD
:
sub
.
WeeklyUsageUSD
,
MonthlyUsageUSD
:
sub
.
MonthlyUsageUSD
,
MonthlyUsageUSD
:
sub
.
MonthlyUsageUSD
,
AssignedBy
:
sub
.
AssignedBy
,
AssignedAt
:
sub
.
AssignedAt
,
Notes
:
sub
.
Notes
,
CreatedAt
:
sub
.
CreatedAt
,
CreatedAt
:
sub
.
CreatedAt
,
UpdatedAt
:
sub
.
UpdatedAt
,
UpdatedAt
:
sub
.
UpdatedAt
,
User
:
UserFromServiceShallow
(
sub
.
User
),
User
:
UserFromServiceShallow
(
sub
.
User
),
Group
:
GroupFromServiceShallow
(
sub
.
Group
),
Group
:
GroupFromServiceShallow
(
sub
.
Group
),
AssignedByUser
:
UserFromServiceShallow
(
sub
.
AssignedByUser
),
}
}
}
}
...
@@ -402,9 +502,9 @@ func BulkAssignResultFromService(r *service.BulkAssignResult) *BulkAssignResult
...
@@ -402,9 +502,9 @@ func BulkAssignResultFromService(r *service.BulkAssignResult) *BulkAssignResult
if
r
==
nil
{
if
r
==
nil
{
return
nil
return
nil
}
}
subs
:=
make
([]
UserSubscription
,
0
,
len
(
r
.
Subscriptions
))
subs
:=
make
([]
Admin
UserSubscription
,
0
,
len
(
r
.
Subscriptions
))
for
i
:=
range
r
.
Subscriptions
{
for
i
:=
range
r
.
Subscriptions
{
subs
=
append
(
subs
,
*
UserSubscriptionFromService
(
&
r
.
Subscriptions
[
i
]))
subs
=
append
(
subs
,
*
UserSubscriptionFromService
Admin
(
&
r
.
Subscriptions
[
i
]))
}
}
return
&
BulkAssignResult
{
return
&
BulkAssignResult
{
SuccessCount
:
r
.
SuccessCount
,
SuccessCount
:
r
.
SuccessCount
,
...
...
backend/internal/handler/dto/settings.go
View file @
2fe8932c
...
@@ -2,8 +2,12 @@ package dto
...
@@ -2,8 +2,12 @@ package dto
// SystemSettings represents the admin settings API response payload.
// SystemSettings represents the admin settings API response payload.
type
SystemSettings
struct
{
type
SystemSettings
struct
{
RegistrationEnabled
bool
`json:"registration_enabled"`
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
TotpEnabled
bool
`json:"totp_enabled"`
// TOTP 双因素认证
TotpEncryptionKeyConfigured
bool
`json:"totp_encryption_key_configured"`
// TOTP 加密密钥是否已配置
SMTPHost
string
`json:"smtp_host"`
SMTPHost
string
`json:"smtp_host"`
SMTPPort
int
`json:"smtp_port"`
SMTPPort
int
`json:"smtp_port"`
...
@@ -22,13 +26,16 @@ type SystemSettings struct {
...
@@ -22,13 +26,16 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured
bool
`json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectClientSecretConfigured
bool
`json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
SiteName
string
`json:"site_name"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
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"`
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
DefaultBalance
float64
`json:"default_balance"`
...
@@ -52,19 +59,25 @@ type SystemSettings struct {
...
@@ -52,19 +59,25 @@ type SystemSettings struct {
}
}
type
PublicSettings
struct
{
type
PublicSettings
struct
{
RegistrationEnabled
bool
`json:"registration_enabled"`
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
TurnstileEnabled
bool
`json:"turnstile_enabled"`
PromoCodeEnabled
bool
`json:"promo_code_enabled"`
TurnstileSiteKey
string
`json:"turnstile_site_key"`
PasswordResetEnabled
bool
`json:"password_reset_enabled"`
SiteName
string
`json:"site_name"`
TotpEnabled
bool
`json:"totp_enabled"`
// TOTP 双因素认证
SiteLogo
string
`json:"site_logo"`
TurnstileEnabled
bool
`json:"turnstile_enabled"`
SiteSubtitle
string
`json:"site_subtitle"`
TurnstileSiteKey
string
`json:"turnstile_site_key"`
APIBaseURL
string
`json:"api_base_url"`
SiteName
string
`json:"site_name"`
ContactInfo
string
`json:"contact_info"`
SiteLogo
string
`json:"site_logo"`
DocURL
string
`json:"doc_url"`
SiteSubtitle
string
`json:"site_subtitle"`
HomeContent
string
`json:"home_content"`
APIBaseURL
string
`json:"api_base_url"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
ContactInfo
string
`json:"contact_info"`
Version
string
`json:"version"`
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"`
}
}
// StreamTimeoutSettings 流超时处理配置 DTO
// StreamTimeoutSettings 流超时处理配置 DTO
...
...
backend/internal/handler/dto/types.go
View file @
2fe8932c
...
@@ -6,7 +6,6 @@ type User struct {
...
@@ -6,7 +6,6 @@ type User struct {
ID
int64
`json:"id"`
ID
int64
`json:"id"`
Email
string
`json:"email"`
Email
string
`json:"email"`
Username
string
`json:"username"`
Username
string
`json:"username"`
Notes
string
`json:"notes"`
Role
string
`json:"role"`
Role
string
`json:"role"`
Balance
float64
`json:"balance"`
Balance
float64
`json:"balance"`
Concurrency
int
`json:"concurrency"`
Concurrency
int
`json:"concurrency"`
...
@@ -19,6 +18,14 @@ type User struct {
...
@@ -19,6 +18,14 @@ type User struct {
Subscriptions
[]
UserSubscription
`json:"subscriptions,omitempty"`
Subscriptions
[]
UserSubscription
`json:"subscriptions,omitempty"`
}
}
// AdminUser 是管理员接口使用的 user DTO(包含敏感/内部字段)。
// 注意:普通用户接口不得返回 notes 等管理员备注信息。
type
AdminUser
struct
{
User
Notes
string
`json:"notes"`
}
type
APIKey
struct
{
type
APIKey
struct
{
ID
int64
`json:"id"`
ID
int64
`json:"id"`
UserID
int64
`json:"user_id"`
UserID
int64
`json:"user_id"`
...
@@ -58,13 +65,19 @@ type Group struct {
...
@@ -58,13 +65,19 @@ type Group struct {
ClaudeCodeOnly
bool
`json:"claude_code_only"`
ClaudeCodeOnly
bool
`json:"claude_code_only"`
FallbackGroupID
*
int64
`json:"fallback_group_id"`
FallbackGroupID
*
int64
`json:"fallback_group_id"`
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 平台使用)
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting
map
[
string
][]
int64
`json:"model_routing"`
ModelRouting
map
[
string
][]
int64
`json:"model_routing"`
ModelRoutingEnabled
bool
`json:"model_routing_enabled"`
ModelRoutingEnabled
bool
`json:"model_routing_enabled"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
AccountCount
int64
`json:"account_count,omitempty"`
AccountCount
int64
`json:"account_count,omitempty"`
}
}
...
@@ -112,6 +125,15 @@ type Account struct {
...
@@ -112,6 +125,15 @@ type Account struct {
MaxSessions
*
int
`json:"max_sessions,omitempty"`
MaxSessions
*
int
`json:"max_sessions,omitempty"`
SessionIdleTimeoutMin
*
int
`json:"session_idle_timeout_minutes,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"`
Proxy
*
Proxy
`json:"proxy,omitempty"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
...
@@ -171,7 +193,6 @@ type RedeemCode struct {
...
@@ -171,7 +193,6 @@ type RedeemCode struct {
Status
string
`json:"status"`
Status
string
`json:"status"`
UsedBy
*
int64
`json:"used_by"`
UsedBy
*
int64
`json:"used_by"`
UsedAt
*
time
.
Time
`json:"used_at"`
UsedAt
*
time
.
Time
`json:"used_at"`
Notes
string
`json:"notes"`
CreatedAt
time
.
Time
`json:"created_at"`
CreatedAt
time
.
Time
`json:"created_at"`
GroupID
*
int64
`json:"group_id"`
GroupID
*
int64
`json:"group_id"`
...
@@ -181,6 +202,15 @@ type RedeemCode struct {
...
@@ -181,6 +202,15 @@ type RedeemCode struct {
Group
*
Group
`json:"group,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
{
type
UsageLog
struct
{
ID
int64
`json:"id"`
ID
int64
`json:"id"`
UserID
int64
`json:"user_id"`
UserID
int64
`json:"user_id"`
...
@@ -200,14 +230,13 @@ type UsageLog struct {
...
@@ -200,14 +230,13 @@ type UsageLog struct {
CacheCreation5mTokens
int
`json:"cache_creation_5m_tokens"`
CacheCreation5mTokens
int
`json:"cache_creation_5m_tokens"`
CacheCreation1hTokens
int
`json:"cache_creation_1h_tokens"`
CacheCreation1hTokens
int
`json:"cache_creation_1h_tokens"`
InputCost
float64
`json:"input_cost"`
InputCost
float64
`json:"input_cost"`
OutputCost
float64
`json:"output_cost"`
OutputCost
float64
`json:"output_cost"`
CacheCreationCost
float64
`json:"cache_creation_cost"`
CacheCreationCost
float64
`json:"cache_creation_cost"`
CacheReadCost
float64
`json:"cache_read_cost"`
CacheReadCost
float64
`json:"cache_read_cost"`
TotalCost
float64
`json:"total_cost"`
TotalCost
float64
`json:"total_cost"`
ActualCost
float64
`json:"actual_cost"`
ActualCost
float64
`json:"actual_cost"`
RateMultiplier
float64
`json:"rate_multiplier"`
RateMultiplier
float64
`json:"rate_multiplier"`
AccountRateMultiplier
*
float64
`json:"account_rate_multiplier"`
BillingType
int8
`json:"billing_type"`
BillingType
int8
`json:"billing_type"`
Stream
bool
`json:"stream"`
Stream
bool
`json:"stream"`
...
@@ -221,18 +250,55 @@ type UsageLog struct {
...
@@ -221,18 +250,55 @@ type UsageLog struct {
// User-Agent
// User-Agent
UserAgent
*
string
`json:"user_agent"`
UserAgent
*
string
`json:"user_agent"`
// IP 地址(仅管理员可见)
IPAddress
*
string
`json:"ip_address,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
CreatedAt
time
.
Time
`json:"created_at"`
User
*
User
`json:"user,omitempty"`
User
*
User
`json:"user,omitempty"`
APIKey
*
APIKey
`json:"api_key,omitempty"`
APIKey
*
APIKey
`json:"api_key,omitempty"`
Account
*
AccountSummary
`json:"account,omitempty"`
// Use minimal AccountSummary to prevent data leakage
Group
*
Group
`json:"group,omitempty"`
Group
*
Group
`json:"group,omitempty"`
Subscription
*
UserSubscription
`json:"subscription,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.
// AccountSummary is a minimal account info for usage log display.
// It intentionally excludes sensitive fields like Credentials, Proxy, etc.
// It intentionally excludes sensitive fields like Credentials, Proxy, etc.
type
AccountSummary
struct
{
type
AccountSummary
struct
{
...
@@ -264,23 +330,30 @@ type UserSubscription struct {
...
@@ -264,23 +330,30 @@ type UserSubscription struct {
WeeklyUsageUSD
float64
`json:"weekly_usage_usd"`
WeeklyUsageUSD
float64
`json:"weekly_usage_usd"`
MonthlyUsageUSD
float64
`json:"monthly_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"`
AssignedBy
*
int64
`json:"assigned_by"`
AssignedAt
time
.
Time
`json:"assigned_at"`
AssignedAt
time
.
Time
`json:"assigned_at"`
Notes
string
`json:"notes"`
Notes
string
`json:"notes"`
CreatedAt
time
.
Time
`json:"created_at"`
AssignedByUser
*
User
`json:"assigned_by_user,omitempty"`
UpdatedAt
time
.
Time
`json:"updated_at"`
User
*
User
`json:"user,omitempty"`
Group
*
Group
`json:"group,omitempty"`
AssignedByUser
*
User
`json:"assigned_by_user,omitempty"`
}
}
type
BulkAssignResult
struct
{
type
BulkAssignResult
struct
{
SuccessCount
int
`json:"success_count"`
SuccessCount
int
`json:"success_count"`
FailedCount
int
`json:"failed_count"`
FailedCount
int
`json:"failed_count"`
Subscriptions
[]
UserSubscription
`json:"subscriptions"`
Subscriptions
[]
Admin
UserSubscription
`json:"subscriptions"`
Errors
[]
string
`json:"errors"`
Errors
[]
string
`json:"errors"`
}
}
// PromoCode 注册优惠码
// PromoCode 注册优惠码
...
...
backend/internal/handler/gateway_handler.go
View file @
2fe8932c
...
@@ -31,6 +31,8 @@ type GatewayHandler struct {
...
@@ -31,6 +31,8 @@ type GatewayHandler struct {
userService
*
service
.
UserService
userService
*
service
.
UserService
billingCacheService
*
service
.
BillingCacheService
billingCacheService
*
service
.
BillingCacheService
concurrencyHelper
*
ConcurrencyHelper
concurrencyHelper
*
ConcurrencyHelper
maxAccountSwitches
int
maxAccountSwitchesGemini
int
}
}
// NewGatewayHandler creates a new GatewayHandler
// NewGatewayHandler creates a new GatewayHandler
...
@@ -44,8 +46,16 @@ func NewGatewayHandler(
...
@@ -44,8 +46,16 @@ func NewGatewayHandler(
cfg
*
config
.
Config
,
cfg
*
config
.
Config
,
)
*
GatewayHandler
{
)
*
GatewayHandler
{
pingInterval
:=
time
.
Duration
(
0
)
pingInterval
:=
time
.
Duration
(
0
)
maxAccountSwitches
:=
10
maxAccountSwitchesGemini
:=
3
if
cfg
!=
nil
{
if
cfg
!=
nil
{
pingInterval
=
time
.
Duration
(
cfg
.
Concurrency
.
PingInterval
)
*
time
.
Second
pingInterval
=
time
.
Duration
(
cfg
.
Concurrency
.
PingInterval
)
*
time
.
Second
if
cfg
.
Gateway
.
MaxAccountSwitches
>
0
{
maxAccountSwitches
=
cfg
.
Gateway
.
MaxAccountSwitches
}
if
cfg
.
Gateway
.
MaxAccountSwitchesGemini
>
0
{
maxAccountSwitchesGemini
=
cfg
.
Gateway
.
MaxAccountSwitchesGemini
}
}
}
return
&
GatewayHandler
{
return
&
GatewayHandler
{
gatewayService
:
gatewayService
,
gatewayService
:
gatewayService
,
...
@@ -54,6 +64,8 @@ func NewGatewayHandler(
...
@@ -54,6 +64,8 @@ func NewGatewayHandler(
userService
:
userService
,
userService
:
userService
,
billingCacheService
:
billingCacheService
,
billingCacheService
:
billingCacheService
,
concurrencyHelper
:
NewConcurrencyHelper
(
concurrencyService
,
SSEPingFormatClaude
,
pingInterval
),
concurrencyHelper
:
NewConcurrencyHelper
(
concurrencyService
,
SSEPingFormatClaude
,
pingInterval
),
maxAccountSwitches
:
maxAccountSwitches
,
maxAccountSwitchesGemini
:
maxAccountSwitchesGemini
,
}
}
}
}
...
@@ -179,7 +191,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -179,7 +191,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
if
platform
==
service
.
PlatformGemini
{
if
platform
==
service
.
PlatformGemini
{
const
maxAccountSwitches
=
3
maxAccountSwitches
:=
h
.
maxAccountSwitches
Gemini
switchCount
:=
0
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
lastFailoverStatus
:=
0
lastFailoverStatus
:=
0
...
@@ -197,17 +209,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -197,17 +209,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
account
:=
selection
.
Account
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
setOpsSelectedAccount
(
c
,
account
.
ID
)
// 检查预热请求拦截(在账号选择后、转发前检查)
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if
account
.
IsInterceptWarmupEnabled
()
&&
isWarmupRequest
(
body
)
{
if
account
.
IsInterceptWarmupEnabled
()
{
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
interceptType
:=
detectInterceptType
(
body
)
selection
.
ReleaseFunc
()
if
interceptType
!=
InterceptTypeNone
{
}
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
if
reqStream
{
selection
.
ReleaseFunc
()
sendMockWarmupStream
(
c
,
reqModel
)
}
}
else
{
if
reqStream
{
sendMockWarmupResponse
(
c
,
reqModel
)
sendMockInterceptStream
(
c
,
reqModel
,
interceptType
)
}
else
{
sendMockInterceptResponse
(
c
,
reqModel
,
interceptType
)
}
return
}
}
return
}
}
// 3. 获取账号并发槽位
// 3. 获取账号并发槽位
...
@@ -313,7 +328,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -313,7 +328,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
}
}
const
maxAccountSwitches
=
10
maxAccountSwitches
:=
h
.
maxAccountSwitches
switchCount
:=
0
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
lastFailoverStatus
:=
0
lastFailoverStatus
:=
0
...
@@ -332,17 +347,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
...
@@ -332,17 +347,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
account
:=
selection
.
Account
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
setOpsSelectedAccount
(
c
,
account
.
ID
)
// 检查预热请求拦截(在账号选择后、转发前检查)
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if
account
.
IsInterceptWarmupEnabled
()
&&
isWarmupRequest
(
body
)
{
if
account
.
IsInterceptWarmupEnabled
()
{
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
interceptType
:=
detectInterceptType
(
body
)
selection
.
ReleaseFunc
()
if
interceptType
!=
InterceptTypeNone
{
}
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
if
reqStream
{
selection
.
ReleaseFunc
()
sendMockWarmupStream
(
c
,
reqModel
)
}
}
else
{
if
reqStream
{
sendMockWarmupResponse
(
c
,
reqModel
)
sendMockInterceptStream
(
c
,
reqModel
,
interceptType
)
}
else
{
sendMockInterceptResponse
(
c
,
reqModel
,
interceptType
)
}
return
}
}
return
}
}
// 3. 获取账号并发槽位
// 3. 获取账号并发槽位
...
@@ -756,17 +774,30 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
...
@@ -756,17 +774,30 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
}
}
}
}
// isWarmupRequest 检测是否为预热请求(标题生成、Warmup等)
// InterceptType 表示请求拦截类型
func
isWarmupRequest
(
body
[]
byte
)
bool
{
type
InterceptType
int
// 快速检查:如果body不包含关键字,直接返回false
const
(
InterceptTypeNone
InterceptType
=
iota
InterceptTypeWarmup
// 预热请求(返回 "New Conversation")
InterceptTypeSuggestionMode
// SUGGESTION MODE(返回空字符串)
)
// detectInterceptType 检测请求是否需要拦截,返回拦截类型
func
detectInterceptType
(
body
[]
byte
)
InterceptType
{
// 快速检查:如果不包含任何关键字,直接返回
bodyStr
:=
string
(
body
)
bodyStr
:=
string
(
body
)
if
!
strings
.
Contains
(
bodyStr
,
"title"
)
&&
!
strings
.
Contains
(
bodyStr
,
"Warmup"
)
{
hasSuggestionMode
:=
strings
.
Contains
(
bodyStr
,
"[SUGGESTION MODE:"
)
return
false
hasWarmupKeyword
:=
strings
.
Contains
(
bodyStr
,
"title"
)
||
strings
.
Contains
(
bodyStr
,
"Warmup"
)
if
!
hasSuggestionMode
&&
!
hasWarmupKeyword
{
return
InterceptTypeNone
}
}
// 解析
完整
请求
// 解析请求
(只解析一次)
var
req
struct
{
var
req
struct
{
Messages
[]
struct
{
Messages
[]
struct
{
Role
string
`json:"role"`
Content
[]
struct
{
Content
[]
struct
{
Type
string
`json:"type"`
Type
string
`json:"type"`
Text
string
`json:"text"`
Text
string
`json:"text"`
...
@@ -777,43 +808,71 @@ func isWarmupRequest(body []byte) bool {
...
@@ -777,43 +808,71 @@ func isWarmupRequest(body []byte) bool {
}
`json:"system"`
}
`json:"system"`
}
}
if
err
:=
json
.
Unmarshal
(
body
,
&
req
);
err
!=
nil
{
if
err
:=
json
.
Unmarshal
(
body
,
&
req
);
err
!=
nil
{
return
fals
e
return
InterceptTypeNon
e
}
}
// 检查 messages 中的标题提示模式
// 检查 SUGGESTION MODE(最后一条 user 消息)
for
_
,
msg
:=
range
req
.
Messages
{
if
hasSuggestionMode
&&
len
(
req
.
Messages
)
>
0
{
for
_
,
content
:=
range
msg
.
Content
{
lastMsg
:=
req
.
Messages
[
len
(
req
.
Messages
)
-
1
]
if
content
.
Type
==
"text"
{
if
lastMsg
.
Role
==
"user"
&&
len
(
lastMsg
.
Content
)
>
0
&&
if
strings
.
Contains
(
content
.
Text
,
"Please write a 5-10 word title for the following conversation:"
)
||
lastMsg
.
Content
[
0
]
.
Type
==
"text"
&&
content
.
Text
==
"Warmup"
{
strings
.
HasPrefix
(
lastMsg
.
Content
[
0
]
.
Text
,
"[SUGGESTION MODE:"
)
{
return
true
return
InterceptTypeSuggestionMode
}
}
}
}
}
}
// 检查 system 中的标题提取模式
// 检查 Warmup 请求
for
_
,
system
:=
range
req
.
System
{
if
hasWarmupKeyword
{
if
strings
.
Contains
(
system
.
Text
,
"nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title"
)
{
// 检查 messages 中的标题提示模式
return
true
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 响应(用于
预热
请求拦截)
// sendMock
Intercept
Stream 发送流式 mock 响应(用于请求拦截)
func
sendMock
Warmup
Stream
(
c
*
gin
.
Context
,
model
string
)
{
func
sendMock
Intercept
Stream
(
c
*
gin
.
Context
,
model
string
,
interceptType
InterceptType
)
{
c
.
Header
(
"Content-Type"
,
"text/event-stream"
)
c
.
Header
(
"Content-Type"
,
"text/event-stream"
)
c
.
Header
(
"Cache-Control"
,
"no-cache"
)
c
.
Header
(
"Cache-Control"
,
"no-cache"
)
c
.
Header
(
"Connection"
,
"keep-alive"
)
c
.
Header
(
"Connection"
,
"keep-alive"
)
c
.
Header
(
"X-Accel-Buffering"
,
"no"
)
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
// Build message_start event with proper JSON marshaling
messageStart
:=
map
[
string
]
any
{
messageStart
:=
map
[
string
]
any
{
"type"
:
"message_start"
,
"type"
:
"message_start"
,
"message"
:
map
[
string
]
any
{
"message"
:
map
[
string
]
any
{
"id"
:
"
msg
_mock_warmup"
,
"id"
:
msg
ID
,
"type"
:
"message"
,
"type"
:
"message"
,
"role"
:
"assistant"
,
"role"
:
"assistant"
,
"model"
:
model
,
"model"
:
model
,
...
@@ -828,16 +887,46 @@ func sendMockWarmupStream(c *gin.Context, model string) {
...
@@ -828,16 +887,46 @@ func sendMockWarmupStream(c *gin.Context, model string) {
}
}
messageStartJSON
,
_
:=
json
.
Marshal
(
messageStart
)
messageStartJSON
,
_
:=
json
.
Marshal
(
messageStart
)
// Build events
events
:=
[]
string
{
events
:=
[]
string
{
`event: message_start`
+
"
\n
"
+
`data: `
+
string
(
messageStartJSON
),
`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_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
{
for
_
,
event
:=
range
events
{
_
,
_
=
c
.
Writer
.
WriteString
(
event
+
"
\n\n
"
)
_
,
_
=
c
.
Writer
.
WriteString
(
event
+
"
\n\n
"
)
c
.
Writer
.
Flush
()
c
.
Writer
.
Flush
()
...
@@ -845,18 +934,32 @@ func sendMockWarmupStream(c *gin.Context, model string) {
...
@@ -845,18 +934,32 @@ func sendMockWarmupStream(c *gin.Context, model string) {
}
}
}
}
// sendMockWarmupResponse 发送非流式 mock 响应(用于预热请求拦截)
// sendMockInterceptResponse 发送非流式 mock 响应(用于请求拦截)
func
sendMockWarmupResponse
(
c
*
gin
.
Context
,
model
string
)
{
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
{
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"id"
:
"
msg
_mock_warmup"
,
"id"
:
msg
ID
,
"type"
:
"message"
,
"type"
:
"message"
,
"role"
:
"assistant"
,
"role"
:
"assistant"
,
"model"
:
model
,
"model"
:
model
,
"content"
:
[]
gin
.
H
{{
"type"
:
"text"
,
"text"
:
"New Conversation"
}},
"content"
:
[]
gin
.
H
{{
"type"
:
"text"
,
"text"
:
text
}},
"stop_reason"
:
"end_turn"
,
"stop_reason"
:
"end_turn"
,
"usage"
:
gin
.
H
{
"usage"
:
gin
.
H
{
"input_tokens"
:
10
,
"input_tokens"
:
10
,
"output_tokens"
:
2
,
"output_tokens"
:
outputTokens
,
},
},
})
})
}
}
...
...
backend/internal/handler/gemini_cli_session_test.go
0 → 100644
View file @
2fe8932c
//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 @
2fe8932c
package
handler
package
handler
import
(
import
(
"bytes"
"context"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"errors"
"io"
"io"
"log"
"log"
"net/http"
"net/http"
"regexp"
"strings"
"strings"
"time"
"time"
...
@@ -19,6 +23,17 @@ import (
...
@@ -19,6 +23,17 @@ import (
"github.com/gin-gonic/gin"
"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:
// GeminiV1BetaListModels proxies:
// GET /v1beta/models
// GET /v1beta/models
func
(
h
*
GatewayHandler
)
GeminiV1BetaListModels
(
c
*
gin
.
Context
)
{
func
(
h
*
GatewayHandler
)
GeminiV1BetaListModels
(
c
*
gin
.
Context
)
{
...
@@ -214,13 +229,27 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
...
@@ -214,13 +229,27 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
}
}
// 3) select account (sticky session based on request body)
// 3) select account (sticky session based on request body)
parsedReq
,
_
:=
service
.
ParseGatewayRequest
(
body
)
// 优先使用 Gemini CLI 的会话标识(privileged-user-id + tmp 目录哈希)
sessionHash
:=
h
.
gatewayService
.
GenerateSessionHash
(
parsedReq
)
sessionHash
:=
extractGeminiCLISessionHash
(
c
,
body
)
if
sessionHash
==
""
{
// Fallback: 使用通用的会话哈希生成逻辑(适用于其他客户端)
parsedReq
,
_
:=
service
.
ParseGatewayRequest
(
body
)
sessionHash
=
h
.
gatewayService
.
GenerateSessionHash
(
parsedReq
)
}
sessionKey
:=
sessionHash
sessionKey
:=
sessionHash
if
sessionHash
!=
""
{
if
sessionHash
!=
""
{
sessionKey
=
"gemini:"
+
sessionHash
sessionKey
=
"gemini:"
+
sessionHash
}
}
const
maxAccountSwitches
=
3
// 查询粘性会话绑定的账号 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
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
lastFailoverStatus
:=
0
lastFailoverStatus
:=
0
...
@@ -238,6 +267,24 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
...
@@ -238,6 +267,24 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
account
:=
selection
.
Account
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
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
// 4) account concurrency slot
accountReleaseFunc
:=
selection
.
ReleaseFunc
accountReleaseFunc
:=
selection
.
ReleaseFunc
if
!
selection
.
Acquired
{
if
!
selection
.
Acquired
{
...
@@ -433,3 +480,38 @@ func shouldFallbackGeminiModels(res *service.UpstreamHTTPResult) bool {
...
@@ -433,3 +480,38 @@ func shouldFallbackGeminiModels(res *service.UpstreamHTTPResult) bool {
}
}
return
false
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 @
2fe8932c
...
@@ -37,6 +37,7 @@ type Handlers struct {
...
@@ -37,6 +37,7 @@ type Handlers struct {
Gateway
*
GatewayHandler
Gateway
*
GatewayHandler
OpenAIGateway
*
OpenAIGatewayHandler
OpenAIGateway
*
OpenAIGatewayHandler
Setting
*
SettingHandler
Setting
*
SettingHandler
Totp
*
TotpHandler
}
}
// BuildInfo contains build-time information
// BuildInfo contains build-time information
...
...
backend/internal/handler/openai_gateway_handler.go
View file @
2fe8932c
...
@@ -25,6 +25,7 @@ type OpenAIGatewayHandler struct {
...
@@ -25,6 +25,7 @@ type OpenAIGatewayHandler struct {
gatewayService
*
service
.
OpenAIGatewayService
gatewayService
*
service
.
OpenAIGatewayService
billingCacheService
*
service
.
BillingCacheService
billingCacheService
*
service
.
BillingCacheService
concurrencyHelper
*
ConcurrencyHelper
concurrencyHelper
*
ConcurrencyHelper
maxAccountSwitches
int
}
}
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
...
@@ -35,13 +36,18 @@ func NewOpenAIGatewayHandler(
...
@@ -35,13 +36,18 @@ func NewOpenAIGatewayHandler(
cfg
*
config
.
Config
,
cfg
*
config
.
Config
,
)
*
OpenAIGatewayHandler
{
)
*
OpenAIGatewayHandler
{
pingInterval
:=
time
.
Duration
(
0
)
pingInterval
:=
time
.
Duration
(
0
)
maxAccountSwitches
:=
3
if
cfg
!=
nil
{
if
cfg
!=
nil
{
pingInterval
=
time
.
Duration
(
cfg
.
Concurrency
.
PingInterval
)
*
time
.
Second
pingInterval
=
time
.
Duration
(
cfg
.
Concurrency
.
PingInterval
)
*
time
.
Second
if
cfg
.
Gateway
.
MaxAccountSwitches
>
0
{
maxAccountSwitches
=
cfg
.
Gateway
.
MaxAccountSwitches
}
}
}
return
&
OpenAIGatewayHandler
{
return
&
OpenAIGatewayHandler
{
gatewayService
:
gatewayService
,
gatewayService
:
gatewayService
,
billingCacheService
:
billingCacheService
,
billingCacheService
:
billingCacheService
,
concurrencyHelper
:
NewConcurrencyHelper
(
concurrencyService
,
SSEPingFormatComment
,
pingInterval
),
concurrencyHelper
:
NewConcurrencyHelper
(
concurrencyService
,
SSEPingFormatComment
,
pingInterval
),
maxAccountSwitches
:
maxAccountSwitches
,
}
}
}
}
...
@@ -186,10 +192,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
...
@@ -186,10 +192,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
return
}
}
// Generate session hash (
from
header f
or OpenAI
)
// Generate session hash (header f
irst; fallback to prompt_cache_key
)
sessionHash
:=
h
.
gatewayService
.
GenerateSessionHash
(
c
)
sessionHash
:=
h
.
gatewayService
.
GenerateSessionHash
(
c
,
reqBody
)
const
maxAccountSwitches
=
3
maxAccountSwitches
:=
h
.
maxAccountSwitches
switchCount
:=
0
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
lastFailoverStatus
:=
0
lastFailoverStatus
:=
0
...
...
backend/internal/handler/setting_handler.go
View file @
2fe8932c
...
@@ -32,18 +32,24 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
...
@@ -32,18 +32,24 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
}
}
response
.
Success
(
c
,
dto
.
PublicSettings
{
response
.
Success
(
c
,
dto
.
PublicSettings
{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
TurnstileEnabled
:
settings
.
TurnstileEnabled
,
PromoCodeEnabled
:
settings
.
PromoCodeEnabled
,
TurnstileSiteKey
:
settings
.
TurnstileSiteKey
,
PasswordResetEnabled
:
settings
.
PasswordResetEnabled
,
SiteName
:
settings
.
SiteName
,
TotpEnabled
:
settings
.
TotpEnabled
,
SiteLogo
:
settings
.
SiteLogo
,
TurnstileEnabled
:
settings
.
TurnstileEnabled
,
SiteSubtitle
:
settings
.
SiteSubtitle
,
TurnstileSiteKey
:
settings
.
TurnstileSiteKey
,
APIBaseURL
:
settings
.
APIBaseURL
,
SiteName
:
settings
.
SiteName
,
ContactInfo
:
settings
.
ContactInfo
,
SiteLogo
:
settings
.
SiteLogo
,
DocURL
:
settings
.
DocURL
,
SiteSubtitle
:
settings
.
SiteSubtitle
,
HomeContent
:
settings
.
HomeContent
,
APIBaseURL
:
settings
.
APIBaseURL
,
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
ContactInfo
:
settings
.
ContactInfo
,
Version
:
h
.
version
,
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 @
2fe8932c
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
})
}
Prev
1
2
3
4
5
6
7
…
14
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