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
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) {
// GetUsageTrend handles getting usage trend data
// 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
)
{
startTime
,
endTime
:=
parseTimeRange
(
c
)
granularity
:=
c
.
DefaultQuery
(
"granularity"
,
"day"
)
...
...
@@ -195,6 +195,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
var
model
string
var
stream
*
bool
var
billingType
*
int8
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
);
err
==
nil
{
...
...
@@ -224,8 +225,17 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
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
{
response
.
Error
(
c
,
500
,
"Failed to get usage trend"
)
return
...
...
@@ -241,13 +251,14 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
// GetModelStats handles getting model usage statistics
// 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
)
{
startTime
,
endTime
:=
parseTimeRange
(
c
)
// Parse optional filter params
var
userID
,
apiKeyID
,
accountID
,
groupID
int64
var
stream
*
bool
var
billingType
*
int8
if
userIDStr
:=
c
.
Query
(
"user_id"
);
userIDStr
!=
""
{
if
id
,
err
:=
strconv
.
ParseInt
(
userIDStr
,
10
,
64
);
err
==
nil
{
...
...
@@ -274,8 +285,17 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
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
{
response
.
Error
(
c
,
500
,
"Failed to get model statistics"
)
return
...
...
backend/internal/handler/admin/group_handler.go
View file @
2fe8932c
...
...
@@ -94,9 +94,9 @@ func (h *GroupHandler) List(c *gin.Context) {
return
}
outGroups
:=
make
([]
dto
.
Group
,
0
,
len
(
groups
))
outGroups
:=
make
([]
dto
.
Admin
Group
,
0
,
len
(
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
)
}
...
...
@@ -120,9 +120,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
return
}
outGroups
:=
make
([]
dto
.
Group
,
0
,
len
(
groups
))
outGroups
:=
make
([]
dto
.
Admin
Group
,
0
,
len
(
groups
))
for
i
:=
range
groups
{
outGroups
=
append
(
outGroups
,
*
dto
.
GroupFromService
(
&
groups
[
i
]))
outGroups
=
append
(
outGroups
,
*
dto
.
GroupFromService
Admin
(
&
groups
[
i
]))
}
response
.
Success
(
c
,
outGroups
)
}
...
...
@@ -142,7 +142,7 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
GroupFromService
(
group
))
response
.
Success
(
c
,
dto
.
GroupFromService
Admin
(
group
))
}
// Create handles creating a new group
...
...
@@ -177,7 +177,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
GroupFromService
(
group
))
response
.
Success
(
c
,
dto
.
GroupFromService
Admin
(
group
))
}
// Update handles updating a group
...
...
@@ -219,7 +219,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
GroupFromService
(
group
))
response
.
Success
(
c
,
dto
.
GroupFromService
Admin
(
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) {
return
}
out
:=
make
([]
dto
.
RedeemCode
,
0
,
len
(
codes
))
out
:=
make
([]
dto
.
Admin
RedeemCode
,
0
,
len
(
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
)
}
...
...
@@ -76,7 +76,7 @@ func (h *RedeemHandler) GetByID(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
(
code
))
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
Admin
(
code
))
}
// Generate handles generating new redeem codes
...
...
@@ -100,9 +100,9 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
return
}
out
:=
make
([]
dto
.
RedeemCode
,
0
,
len
(
codes
))
out
:=
make
([]
dto
.
Admin
RedeemCode
,
0
,
len
(
codes
))
for
i
:=
range
codes
{
out
=
append
(
out
,
*
dto
.
RedeemCodeFromService
(
&
codes
[
i
]))
out
=
append
(
out
,
*
dto
.
RedeemCodeFromService
Admin
(
&
codes
[
i
]))
}
response
.
Success
(
c
,
out
)
}
...
...
@@ -163,7 +163,7 @@ func (h *RedeemHandler) Expire(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
(
code
))
response
.
Success
(
c
,
dto
.
RedeemCodeFromService
Admin
(
code
))
}
// 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) {
response
.
Success
(
c
,
dto
.
SystemSettings
{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
settings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
settings
.
PasswordResetEnabled
,
TotpEnabled
:
settings
.
TotpEnabled
,
TotpEncryptionKeyConfigured
:
h
.
settingService
.
IsTotpEncryptionKeyConfigured
(),
SMTPHost
:
settings
.
SMTPHost
,
SMTPPort
:
settings
.
SMTPPort
,
SMTPUsername
:
settings
.
SMTPUsername
,
...
...
@@ -68,6 +72,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ContactInfo
:
settings
.
ContactInfo
,
DocURL
:
settings
.
DocURL
,
HomeContent
:
settings
.
HomeContent
,
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
DefaultBalance
:
settings
.
DefaultBalance
,
EnableModelFallback
:
settings
.
EnableModelFallback
,
...
...
@@ -87,8 +94,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
// UpdateSettingsRequest 更新设置请求
type
UpdateSettingsRequest
struct
{
// 注册设置
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
RegistrationEnabled
bool
`json:"registration_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"`
...
...
@@ -111,13 +121,16 @@ type UpdateSettingsRequest struct {
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
// OEM设置
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
*
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
*
string
`json:"purchase_subscription_url"`
// 默认配置
DefaultConcurrency
int
`json:"default_concurrency"`
...
...
@@ -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 参数验证
if
req
.
LinuxDoConnectEnabled
{
req
.
LinuxDoConnectClientID
=
strings
.
TrimSpace
(
req
.
LinuxDoConnectClientID
)
...
...
@@ -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).
if
req
.
OpsMetricsIntervalSeconds
!=
nil
{
v
:=
*
req
.
OpsMetricsIntervalSeconds
...
...
@@ -236,38 +287,44 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
settings
:=
&
service
.
SystemSettings
{
RegistrationEnabled
:
req
.
RegistrationEnabled
,
EmailVerifyEnabled
:
req
.
EmailVerifyEnabled
,
SMTPHost
:
req
.
SMTPHost
,
SMTPPort
:
req
.
SMTPPort
,
SMTPUsername
:
req
.
SMTPUsername
,
SMTPPassword
:
req
.
SMTPPassword
,
SMTPFrom
:
req
.
SMTPFrom
,
SMTPFromName
:
req
.
SMTPFromName
,
SMTPUseTLS
:
req
.
SMTPUseTLS
,
TurnstileEnabled
:
req
.
TurnstileEnabled
,
TurnstileSiteKey
:
req
.
TurnstileSiteKey
,
TurnstileSecretKey
:
req
.
TurnstileSecretKey
,
LinuxDoConnectEnabled
:
req
.
LinuxDoConnectEnabled
,
LinuxDoConnectClientID
:
req
.
LinuxDoConnectClientID
,
LinuxDoConnectClientSecret
:
req
.
LinuxDoConnectClientSecret
,
LinuxDoConnectRedirectURL
:
req
.
LinuxDoConnectRedirectURL
,
SiteName
:
req
.
SiteName
,
SiteLogo
:
req
.
SiteLogo
,
SiteSubtitle
:
req
.
SiteSubtitle
,
APIBaseURL
:
req
.
APIBaseURL
,
ContactInfo
:
req
.
ContactInfo
,
DocURL
:
req
.
DocURL
,
HomeContent
:
req
.
HomeContent
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
DefaultBalance
:
req
.
DefaultBalance
,
EnableModelFallback
:
req
.
EnableModelFallback
,
FallbackModelAnthropic
:
req
.
FallbackModelAnthropic
,
FallbackModelOpenAI
:
req
.
FallbackModelOpenAI
,
FallbackModelGemini
:
req
.
FallbackModelGemini
,
FallbackModelAntigravity
:
req
.
FallbackModelAntigravity
,
EnableIdentityPatch
:
req
.
EnableIdentityPatch
,
IdentityPatchPrompt
:
req
.
IdentityPatchPrompt
,
RegistrationEnabled
:
req
.
RegistrationEnabled
,
EmailVerifyEnabled
:
req
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
req
.
PromoCodeEnabled
,
PasswordResetEnabled
:
req
.
PasswordResetEnabled
,
TotpEnabled
:
req
.
TotpEnabled
,
SMTPHost
:
req
.
SMTPHost
,
SMTPPort
:
req
.
SMTPPort
,
SMTPUsername
:
req
.
SMTPUsername
,
SMTPPassword
:
req
.
SMTPPassword
,
SMTPFrom
:
req
.
SMTPFrom
,
SMTPFromName
:
req
.
SMTPFromName
,
SMTPUseTLS
:
req
.
SMTPUseTLS
,
TurnstileEnabled
:
req
.
TurnstileEnabled
,
TurnstileSiteKey
:
req
.
TurnstileSiteKey
,
TurnstileSecretKey
:
req
.
TurnstileSecretKey
,
LinuxDoConnectEnabled
:
req
.
LinuxDoConnectEnabled
,
LinuxDoConnectClientID
:
req
.
LinuxDoConnectClientID
,
LinuxDoConnectClientSecret
:
req
.
LinuxDoConnectClientSecret
,
LinuxDoConnectRedirectURL
:
req
.
LinuxDoConnectRedirectURL
,
SiteName
:
req
.
SiteName
,
SiteLogo
:
req
.
SiteLogo
,
SiteSubtitle
:
req
.
SiteSubtitle
,
APIBaseURL
:
req
.
APIBaseURL
,
ContactInfo
:
req
.
ContactInfo
,
DocURL
:
req
.
DocURL
,
HomeContent
:
req
.
HomeContent
,
HideCcsImportButton
:
req
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
purchaseEnabled
,
PurchaseSubscriptionURL
:
purchaseURL
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
DefaultBalance
:
req
.
DefaultBalance
,
EnableModelFallback
:
req
.
EnableModelFallback
,
FallbackModelAnthropic
:
req
.
FallbackModelAnthropic
,
FallbackModelOpenAI
:
req
.
FallbackModelOpenAI
,
FallbackModelGemini
:
req
.
FallbackModelGemini
,
FallbackModelAntigravity
:
req
.
FallbackModelAntigravity
,
EnableIdentityPatch
:
req
.
EnableIdentityPatch
,
IdentityPatchPrompt
:
req
.
IdentityPatchPrompt
,
OpsMonitoringEnabled
:
func
()
bool
{
if
req
.
OpsMonitoringEnabled
!=
nil
{
return
*
req
.
OpsMonitoringEnabled
...
...
@@ -311,6 +368,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response
.
Success
(
c
,
dto
.
SystemSettings
{
RegistrationEnabled
:
updatedSettings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
updatedSettings
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
updatedSettings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
updatedSettings
.
PasswordResetEnabled
,
TotpEnabled
:
updatedSettings
.
TotpEnabled
,
TotpEncryptionKeyConfigured
:
h
.
settingService
.
IsTotpEncryptionKeyConfigured
(),
SMTPHost
:
updatedSettings
.
SMTPHost
,
SMTPPort
:
updatedSettings
.
SMTPPort
,
SMTPUsername
:
updatedSettings
.
SMTPUsername
,
...
...
@@ -332,6 +393,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ContactInfo
:
updatedSettings
.
ContactInfo
,
DocURL
:
updatedSettings
.
DocURL
,
HomeContent
:
updatedSettings
.
HomeContent
,
HideCcsImportButton
:
updatedSettings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
updatedSettings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
updatedSettings
.
PurchaseSubscriptionURL
,
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
DefaultBalance
:
updatedSettings
.
DefaultBalance
,
EnableModelFallback
:
updatedSettings
.
EnableModelFallback
,
...
...
@@ -376,6 +440,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
EmailVerifyEnabled
!=
after
.
EmailVerifyEnabled
{
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
{
changed
=
append
(
changed
,
"smtp_host"
)
}
...
...
@@ -439,6 +509,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
HomeContent
!=
after
.
HomeContent
{
changed
=
append
(
changed
,
"home_content"
)
}
if
before
.
HideCcsImportButton
!=
after
.
HideCcsImportButton
{
changed
=
append
(
changed
,
"hide_ccs_import_button"
)
}
if
before
.
DefaultConcurrency
!=
after
.
DefaultConcurrency
{
changed
=
append
(
changed
,
"default_concurrency"
)
}
...
...
backend/internal/handler/admin/subscription_handler.go
View file @
2fe8932c
...
...
@@ -53,9 +53,9 @@ type BulkAssignSubscriptionRequest struct {
Notes
string
`json:"notes"`
}
//
Extend
SubscriptionRequest represents
extend
subscription request
type
Extend
SubscriptionRequest
struct
{
Days
int
`json:"days" binding:"required,min=
1
,max=36500"`
//
max 100 years
//
Adjust
SubscriptionRequest represents
adjust
subscription request
(extend or shorten)
type
Adjust
SubscriptionRequest
struct
{
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
...
...
@@ -77,15 +77,19 @@ func (h *SubscriptionHandler) List(c *gin.Context) {
}
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
{
response
.
ErrorFrom
(
c
,
err
)
return
}
out
:=
make
([]
dto
.
UserSubscription
,
0
,
len
(
subscriptions
))
out
:=
make
([]
dto
.
Admin
UserSubscription
,
0
,
len
(
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
))
}
...
...
@@ -105,7 +109,7 @@ func (h *SubscriptionHandler) GetByID(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
(
subscription
))
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
Admin
(
subscription
))
}
// GetProgress handles getting subscription usage progress
...
...
@@ -150,7 +154,7 @@ func (h *SubscriptionHandler) Assign(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
(
subscription
))
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
Admin
(
subscription
))
}
// BulkAssign handles bulk assigning subscriptions to multiple users
...
...
@@ -180,7 +184,7 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) {
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
func
(
h
*
SubscriptionHandler
)
Extend
(
c
*
gin
.
Context
)
{
subscriptionID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
...
...
@@ -189,7 +193,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
return
}
var
req
Extend
SubscriptionRequest
var
req
Adjust
SubscriptionRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
...
...
@@ -201,7 +205,7 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
(
subscription
))
response
.
Success
(
c
,
dto
.
UserSubscriptionFromService
Admin
(
subscription
))
}
// Revoke handles revoking a subscription
...
...
@@ -239,9 +243,9 @@ func (h *SubscriptionHandler) ListByGroup(c *gin.Context) {
return
}
out
:=
make
([]
dto
.
UserSubscription
,
0
,
len
(
subscriptions
))
out
:=
make
([]
dto
.
Admin
UserSubscription
,
0
,
len
(
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
))
}
...
...
@@ -261,9 +265,9 @@ func (h *SubscriptionHandler) ListByUser(c *gin.Context) {
return
}
out
:=
make
([]
dto
.
UserSubscription
,
0
,
len
(
subscriptions
))
out
:=
make
([]
dto
.
Admin
UserSubscription
,
0
,
len
(
subscriptions
))
for
i
:=
range
subscriptions
{
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
(
&
subscriptions
[
i
]))
out
=
append
(
out
,
*
dto
.
UserSubscriptionFromService
Admin
(
&
subscriptions
[
i
]))
}
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
import
(
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
...
...
@@ -9,6 +12,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"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/gin-gonic/gin"
...
...
@@ -16,9 +20,10 @@ import (
// UsageHandler handles admin usage-related requests
type
UsageHandler
struct
{
usageService
*
service
.
UsageService
apiKeyService
*
service
.
APIKeyService
adminService
service
.
AdminService
usageService
*
service
.
UsageService
apiKeyService
*
service
.
APIKeyService
adminService
service
.
AdminService
cleanupService
*
service
.
UsageCleanupService
}
// NewUsageHandler creates a new admin usage handler
...
...
@@ -26,14 +31,30 @@ func NewUsageHandler(
usageService
*
service
.
UsageService
,
apiKeyService
*
service
.
APIKeyService
,
adminService
service
.
AdminService
,
cleanupService
*
service
.
UsageCleanupService
,
)
*
UsageHandler
{
return
&
UsageHandler
{
usageService
:
usageService
,
apiKeyService
:
apiKeyService
,
adminService
:
adminService
,
usageService
:
usageService
,
apiKeyService
:
apiKeyService
,
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
// GET /api/v1/admin/usage
func
(
h
*
UsageHandler
)
List
(
c
*
gin
.
Context
)
{
...
...
@@ -142,7 +163,7 @@ func (h *UsageHandler) List(c *gin.Context) {
return
}
out
:=
make
([]
dto
.
UsageLog
,
0
,
len
(
records
))
out
:=
make
([]
dto
.
Admin
UsageLog
,
0
,
len
(
records
))
for
i
:=
range
records
{
out
=
append
(
out
,
*
dto
.
UsageLogFromServiceAdmin
(
&
records
[
i
]))
}
...
...
@@ -344,3 +365,162 @@ func (h *UsageHandler) SearchAPIKeys(c *gin.Context) {
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) {
return
}
out
:=
make
([]
dto
.
User
,
0
,
len
(
users
))
out
:=
make
([]
dto
.
Admin
User
,
0
,
len
(
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
)
}
...
...
@@ -129,7 +129,7 @@ func (h *UserHandler) GetByID(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
// Create handles creating a new user
...
...
@@ -155,7 +155,7 @@ func (h *UserHandler) Create(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
// Update handles updating a user
...
...
@@ -189,7 +189,7 @@ func (h *UserHandler) Update(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
// Delete handles deleting a user
...
...
@@ -231,7 +231,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) {
return
}
response
.
Success
(
c
,
dto
.
UserFromService
(
user
))
response
.
Success
(
c
,
dto
.
UserFromService
Admin
(
user
))
}
// GetUserAPIKeys handles getting user's API keys
...
...
backend/internal/handler/auth_handler.go
View file @
2fe8932c
package
handler
import
(
"log/slog"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
...
...
@@ -18,16 +20,18 @@ type AuthHandler struct {
userService
*
service
.
UserService
settingSvc
*
service
.
SettingService
promoService
*
service
.
PromoService
totpService
*
service
.
TotpService
}
// NewAuthHandler creates a new AuthHandler
func
NewAuthHandler
(
cfg
*
config
.
Config
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
settingService
*
service
.
SettingService
,
promoService
*
service
.
PromoService
)
*
AuthHandler
{
func
NewAuthHandler
(
cfg
*
config
.
Config
,
authService
*
service
.
AuthService
,
userService
*
service
.
UserService
,
settingService
*
service
.
SettingService
,
promoService
*
service
.
PromoService
,
totpService
*
service
.
TotpService
)
*
AuthHandler
{
return
&
AuthHandler
{
cfg
:
cfg
,
authService
:
authService
,
userService
:
userService
,
settingSvc
:
settingService
,
promoService
:
promoService
,
totpService
:
totpService
,
}
}
...
...
@@ -144,6 +148,100 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// Check if TOTP 2FA is enabled for this user
if
h
.
totpService
!=
nil
&&
h
.
settingSvc
.
IsTotpEnabled
(
c
.
Request
.
Context
())
&&
user
.
TotpEnabled
{
// Create a temporary login session for 2FA
tempToken
,
err
:=
h
.
totpService
.
CreateLoginSession
(
c
.
Request
.
Context
(),
user
.
ID
,
user
.
Email
)
if
err
!=
nil
{
response
.
InternalError
(
c
,
"Failed to create 2FA session"
)
return
}
response
.
Success
(
c
,
TotpLoginResponse
{
Requires2FA
:
true
,
TempToken
:
tempToken
,
UserEmailMasked
:
service
.
MaskEmail
(
user
.
Email
),
})
return
}
response
.
Success
(
c
,
AuthResponse
{
AccessToken
:
token
,
TokenType
:
"Bearer"
,
User
:
dto
.
UserFromService
(
user
),
})
}
// TotpLoginResponse represents the response when 2FA is required
type
TotpLoginResponse
struct
{
Requires2FA
bool
`json:"requires_2fa"`
TempToken
string
`json:"temp_token,omitempty"`
UserEmailMasked
string
`json:"user_email_masked,omitempty"`
}
// Login2FARequest represents the 2FA login request
type
Login2FARequest
struct
{
TempToken
string
`json:"temp_token" binding:"required"`
TotpCode
string
`json:"totp_code" binding:"required,len=6"`
}
// Login2FA completes the login with 2FA verification
// POST /api/v1/auth/login/2fa
func
(
h
*
AuthHandler
)
Login2FA
(
c
*
gin
.
Context
)
{
var
req
Login2FARequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
slog
.
Debug
(
"login_2fa_request"
,
"temp_token_len"
,
len
(
req
.
TempToken
),
"totp_code_len"
,
len
(
req
.
TotpCode
))
// Get the login session
session
,
err
:=
h
.
totpService
.
GetLoginSession
(
c
.
Request
.
Context
(),
req
.
TempToken
)
if
err
!=
nil
||
session
==
nil
{
tokenPrefix
:=
""
if
len
(
req
.
TempToken
)
>=
8
{
tokenPrefix
=
req
.
TempToken
[
:
8
]
}
slog
.
Debug
(
"login_2fa_session_invalid"
,
"temp_token_prefix"
,
tokenPrefix
,
"error"
,
err
)
response
.
BadRequest
(
c
,
"Invalid or expired 2FA session"
)
return
}
slog
.
Debug
(
"login_2fa_session_found"
,
"user_id"
,
session
.
UserID
,
"email"
,
session
.
Email
)
// Verify the TOTP code
if
err
:=
h
.
totpService
.
VerifyCode
(
c
.
Request
.
Context
(),
session
.
UserID
,
req
.
TotpCode
);
err
!=
nil
{
slog
.
Debug
(
"login_2fa_verify_failed"
,
"user_id"
,
session
.
UserID
,
"error"
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
}
// Delete the login session
_
=
h
.
totpService
.
DeleteLoginSession
(
c
.
Request
.
Context
(),
req
.
TempToken
)
// Get the user
user
,
err
:=
h
.
userService
.
GetByID
(
c
.
Request
.
Context
(),
session
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Generate the JWT token
token
,
err
:=
h
.
authService
.
GenerateToken
(
user
)
if
err
!=
nil
{
response
.
InternalError
(
c
,
"Failed to generate token"
)
return
}
response
.
Success
(
c
,
AuthResponse
{
AccessToken
:
token
,
TokenType
:
"Bearer"
,
...
...
@@ -195,6 +293,15 @@ type ValidatePromoCodeResponse struct {
// ValidatePromoCode 验证优惠码(公开接口,注册前调用)
// POST /api/v1/auth/validate-promo-code
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
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
...
...
@@ -238,3 +345,85 @@ func (h *AuthHandler) ValidatePromoCode(c *gin.Context) {
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 {
ID
:
u
.
ID
,
Email
:
u
.
Email
,
Username
:
u
.
Username
,
Notes
:
u
.
Notes
,
Role
:
u
.
Role
,
Balance
:
u
.
Balance
,
Concurrency
:
u
.
Concurrency
,
...
...
@@ -48,6 +47,22 @@ func UserFromService(u *service.User) *User {
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
{
if
k
==
nil
{
return
nil
...
...
@@ -72,36 +87,29 @@ func GroupFromServiceShallow(g *service.Group) *Group {
if
g
==
nil
{
return
nil
}
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
,
ModelRouting
:
g
.
ModelRouting
,
ModelRoutingEnabled
:
g
.
ModelRoutingEnabled
,
CreatedAt
:
g
.
CreatedAt
,
UpdatedAt
:
g
.
UpdatedAt
,
AccountCount
:
g
.
AccountCount
,
}
out
:=
groupFromServiceBase
(
g
)
return
&
out
}
func
GroupFromService
(
g
*
service
.
Group
)
*
Group
{
if
g
==
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
{
out
.
AccountGroups
=
make
([]
AccountGroup
,
0
,
len
(
g
.
AccountGroups
))
for
i
:=
range
g
.
AccountGroups
{
...
...
@@ -112,6 +120,29 @@ func GroupFromService(g *service.Group) *Group {
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
{
if
a
==
nil
{
return
nil
...
...
@@ -161,6 +192,16 @@ func AccountFromServiceShallow(a *service.Account) *Account {
if
idleTimeout
:=
a
.
GetSessionIdleTimeoutMinutes
();
idleTimeout
>
0
{
out
.
SessionIdleTimeoutMin
=
&
idleTimeout
}
// TLS指纹伪装开关
if
a
.
IsTLSFingerprintEnabled
()
{
enabled
:=
true
out
.
EnableTLSFingerprint
=
&
enabled
}
// 会话ID伪装开关
if
a
.
IsSessionIDMaskingEnabled
()
{
enabled
:=
true
out
.
EnableSessionIDMasking
=
&
enabled
}
}
return
out
...
...
@@ -263,7 +304,24 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode {
if
rc
==
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
,
Code
:
rc
.
Code
,
Type
:
rc
.
Type
,
...
...
@@ -271,7 +329,6 @@ func RedeemCodeFromService(rc *service.RedeemCode) *RedeemCode {
Status
:
rc
.
Status
,
UsedBy
:
rc
.
UsedBy
,
UsedAt
:
rc
.
UsedAt
,
Notes
:
rc
.
Notes
,
CreatedAt
:
rc
.
CreatedAt
,
GroupID
:
rc
.
GroupID
,
ValidityDays
:
rc
.
ValidityDays
,
...
...
@@ -292,14 +349,9 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary {
}
}
// usageLogFromServiceBase is a helper that converts service UsageLog to DTO.
// The account parameter allows caller to control what Account info is included.
// The includeIPAddress parameter controls whether to include the IP address (admin-only).
func
usageLogFromServiceBase
(
l
*
service
.
UsageLog
,
account
*
AccountSummary
,
includeIPAddress
bool
)
*
UsageLog
{
if
l
==
nil
{
return
nil
}
result
:=
&
UsageLog
{
func
usageLogFromServiceUser
(
l
*
service
.
UsageLog
)
UsageLog
{
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。
return
UsageLog
{
ID
:
l
.
ID
,
UserID
:
l
.
UserID
,
APIKeyID
:
l
.
APIKeyID
,
...
...
@@ -321,7 +373,6 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
TotalCost
:
l
.
TotalCost
,
ActualCost
:
l
.
ActualCost
,
RateMultiplier
:
l
.
RateMultiplier
,
AccountRateMultiplier
:
l
.
AccountRateMultiplier
,
BillingType
:
l
.
BillingType
,
Stream
:
l
.
Stream
,
DurationMs
:
l
.
DurationMs
,
...
...
@@ -332,30 +383,63 @@ func usageLogFromServiceBase(l *service.UsageLog, account *AccountSummary, inclu
CreatedAt
:
l
.
CreatedAt
,
User
:
UserFromServiceShallow
(
l
.
User
),
APIKey
:
APIKeyFromService
(
l
.
APIKey
),
Account
:
account
,
Group
:
GroupFromServiceShallow
(
l
.
Group
),
Subscription
:
UserSubscriptionFromService
(
l
.
Subscription
),
}
// IP 地址仅对管理员可见
if
includeIPAddress
{
result
.
IPAddress
=
l
.
IPAddress
}
return
result
}
// UsageLogFromService converts a service UsageLog to DTO for regular users.
// It excludes Account details and IP address - users should not see these.
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.
// 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
{
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
{
...
...
@@ -374,7 +458,27 @@ func UserSubscriptionFromService(sub *service.UserSubscription) *UserSubscriptio
if
sub
==
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
,
UserID
:
sub
.
UserID
,
GroupID
:
sub
.
GroupID
,
...
...
@@ -387,14 +491,10 @@ func UserSubscriptionFromService(sub *service.UserSubscription) *UserSubscriptio
DailyUsageUSD
:
sub
.
DailyUsageUSD
,
WeeklyUsageUSD
:
sub
.
WeeklyUsageUSD
,
MonthlyUsageUSD
:
sub
.
MonthlyUsageUSD
,
AssignedBy
:
sub
.
AssignedBy
,
AssignedAt
:
sub
.
AssignedAt
,
Notes
:
sub
.
Notes
,
CreatedAt
:
sub
.
CreatedAt
,
UpdatedAt
:
sub
.
UpdatedAt
,
User
:
UserFromServiceShallow
(
sub
.
User
),
Group
:
GroupFromServiceShallow
(
sub
.
Group
),
AssignedByUser
:
UserFromServiceShallow
(
sub
.
AssignedByUser
),
}
}
...
...
@@ -402,9 +502,9 @@ func BulkAssignResultFromService(r *service.BulkAssignResult) *BulkAssignResult
if
r
==
nil
{
return
nil
}
subs
:=
make
([]
UserSubscription
,
0
,
len
(
r
.
Subscriptions
))
subs
:=
make
([]
Admin
UserSubscription
,
0
,
len
(
r
.
Subscriptions
))
for
i
:=
range
r
.
Subscriptions
{
subs
=
append
(
subs
,
*
UserSubscriptionFromService
(
&
r
.
Subscriptions
[
i
]))
subs
=
append
(
subs
,
*
UserSubscriptionFromService
Admin
(
&
r
.
Subscriptions
[
i
]))
}
return
&
BulkAssignResult
{
SuccessCount
:
r
.
SuccessCount
,
...
...
backend/internal/handler/dto/settings.go
View file @
2fe8932c
...
...
@@ -2,8 +2,12 @@ package dto
// SystemSettings represents the admin settings API response payload.
type
SystemSettings
struct
{
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
RegistrationEnabled
bool
`json:"registration_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"`
SMTPPort
int
`json:"smtp_port"`
...
...
@@ -22,13 +26,16 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured
bool
`json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
DefaultConcurrency
int
`json:"default_concurrency"`
DefaultBalance
float64
`json:"default_balance"`
...
...
@@ -52,19 +59,25 @@ type SystemSettings struct {
}
type
PublicSettings
struct
{
RegistrationEnabled
bool
`json:"registration_enabled"`
EmailVerifyEnabled
bool
`json:"email_verify_enabled"`
TurnstileEnabled
bool
`json:"turnstile_enabled"`
TurnstileSiteKey
string
`json:"turnstile_site_key"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
DocURL
string
`json:"doc_url"`
HomeContent
string
`json:"home_content"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
Version
string
`json:"version"`
RegistrationEnabled
bool
`json:"registration_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 双因素认证
TurnstileEnabled
bool
`json:"turnstile_enabled"`
TurnstileSiteKey
string
`json:"turnstile_site_key"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
APIBaseURL
string
`json:"api_base_url"`
ContactInfo
string
`json:"contact_info"`
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
...
...
backend/internal/handler/dto/types.go
View file @
2fe8932c
...
...
@@ -6,7 +6,6 @@ type User struct {
ID
int64
`json:"id"`
Email
string
`json:"email"`
Username
string
`json:"username"`
Notes
string
`json:"notes"`
Role
string
`json:"role"`
Balance
float64
`json:"balance"`
Concurrency
int
`json:"concurrency"`
...
...
@@ -19,6 +18,14 @@ type User struct {
Subscriptions
[]
UserSubscription
`json:"subscriptions,omitempty"`
}
// AdminUser 是管理员接口使用的 user DTO(包含敏感/内部字段)。
// 注意:普通用户接口不得返回 notes 等管理员备注信息。
type
AdminUser
struct
{
User
Notes
string
`json:"notes"`
}
type
APIKey
struct
{
ID
int64
`json:"id"`
UserID
int64
`json:"user_id"`
...
...
@@ -58,13 +65,19 @@ type Group struct {
ClaudeCodeOnly
bool
`json:"claude_code_only"`
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 平台使用)
ModelRouting
map
[
string
][]
int64
`json:"model_routing"`
ModelRoutingEnabled
bool
`json:"model_routing_enabled"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
AccountCount
int64
`json:"account_count,omitempty"`
}
...
...
@@ -112,6 +125,15 @@ type Account struct {
MaxSessions
*
int
`json:"max_sessions,omitempty"`
SessionIdleTimeoutMin
*
int
`json:"session_idle_timeout_minutes,omitempty"`
// TLS指纹伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 从 extra 字段提取,方便前端显示和编辑
EnableTLSFingerprint
*
bool
`json:"enable_tls_fingerprint,omitempty"`
// 会话ID伪装(仅 Anthropic OAuth/SetupToken 账号有效)
// 启用后将在15分钟内固定 metadata.user_id 中的 session ID
// 从 extra 字段提取,方便前端显示和编辑
EnableSessionIDMasking
*
bool
`json:"session_id_masking_enabled,omitempty"`
Proxy
*
Proxy
`json:"proxy,omitempty"`
AccountGroups
[]
AccountGroup
`json:"account_groups,omitempty"`
...
...
@@ -171,7 +193,6 @@ type RedeemCode struct {
Status
string
`json:"status"`
UsedBy
*
int64
`json:"used_by"`
UsedAt
*
time
.
Time
`json:"used_at"`
Notes
string
`json:"notes"`
CreatedAt
time
.
Time
`json:"created_at"`
GroupID
*
int64
`json:"group_id"`
...
...
@@ -181,6 +202,15 @@ type RedeemCode struct {
Group
*
Group
`json:"group,omitempty"`
}
// AdminRedeemCode 是管理员接口使用的 redeem code DTO(包含 notes 等字段)。
// 注意:普通用户接口不得返回 notes 等内部信息。
type
AdminRedeemCode
struct
{
RedeemCode
Notes
string
`json:"notes"`
}
// UsageLog 是普通用户接口使用的 usage log DTO(不包含管理员字段)。
type
UsageLog
struct
{
ID
int64
`json:"id"`
UserID
int64
`json:"user_id"`
...
...
@@ -200,14 +230,13 @@ type UsageLog struct {
CacheCreation5mTokens
int
`json:"cache_creation_5m_tokens"`
CacheCreation1hTokens
int
`json:"cache_creation_1h_tokens"`
InputCost
float64
`json:"input_cost"`
OutputCost
float64
`json:"output_cost"`
CacheCreationCost
float64
`json:"cache_creation_cost"`
CacheReadCost
float64
`json:"cache_read_cost"`
TotalCost
float64
`json:"total_cost"`
ActualCost
float64
`json:"actual_cost"`
RateMultiplier
float64
`json:"rate_multiplier"`
AccountRateMultiplier
*
float64
`json:"account_rate_multiplier"`
InputCost
float64
`json:"input_cost"`
OutputCost
float64
`json:"output_cost"`
CacheCreationCost
float64
`json:"cache_creation_cost"`
CacheReadCost
float64
`json:"cache_read_cost"`
TotalCost
float64
`json:"total_cost"`
ActualCost
float64
`json:"actual_cost"`
RateMultiplier
float64
`json:"rate_multiplier"`
BillingType
int8
`json:"billing_type"`
Stream
bool
`json:"stream"`
...
...
@@ -221,18 +250,55 @@ type UsageLog struct {
// User-Agent
UserAgent
*
string
`json:"user_agent"`
// IP 地址(仅管理员可见)
IPAddress
*
string
`json:"ip_address,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
User
*
User
`json:"user,omitempty"`
APIKey
*
APIKey
`json:"api_key,omitempty"`
Account
*
AccountSummary
`json:"account,omitempty"`
// Use minimal AccountSummary to prevent data leakage
Group
*
Group
`json:"group,omitempty"`
Subscription
*
UserSubscription
`json:"subscription,omitempty"`
}
// AdminUsageLog 是管理员接口使用的 usage log DTO(包含管理员字段)。
type
AdminUsageLog
struct
{
UsageLog
// AccountRateMultiplier 账号计费倍率快照(nil 表示按 1.0 处理)
AccountRateMultiplier
*
float64
`json:"account_rate_multiplier"`
// IPAddress 用户请求 IP(仅管理员可见)
IPAddress
*
string
`json:"ip_address,omitempty"`
// Account 最小账号信息(避免泄露敏感字段)
Account
*
AccountSummary
`json:"account,omitempty"`
}
type
UsageCleanupFilters
struct
{
StartTime
time
.
Time
`json:"start_time"`
EndTime
time
.
Time
`json:"end_time"`
UserID
*
int64
`json:"user_id,omitempty"`
APIKeyID
*
int64
`json:"api_key_id,omitempty"`
AccountID
*
int64
`json:"account_id,omitempty"`
GroupID
*
int64
`json:"group_id,omitempty"`
Model
*
string
`json:"model,omitempty"`
Stream
*
bool
`json:"stream,omitempty"`
BillingType
*
int8
`json:"billing_type,omitempty"`
}
type
UsageCleanupTask
struct
{
ID
int64
`json:"id"`
Status
string
`json:"status"`
Filters
UsageCleanupFilters
`json:"filters"`
CreatedBy
int64
`json:"created_by"`
DeletedRows
int64
`json:"deleted_rows"`
ErrorMessage
*
string
`json:"error_message,omitempty"`
CanceledBy
*
int64
`json:"canceled_by,omitempty"`
CanceledAt
*
time
.
Time
`json:"canceled_at,omitempty"`
StartedAt
*
time
.
Time
`json:"started_at,omitempty"`
FinishedAt
*
time
.
Time
`json:"finished_at,omitempty"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
}
// AccountSummary is a minimal account info for usage log display.
// It intentionally excludes sensitive fields like Credentials, Proxy, etc.
type
AccountSummary
struct
{
...
...
@@ -264,23 +330,30 @@ type UserSubscription struct {
WeeklyUsageUSD
float64
`json:"weekly_usage_usd"`
MonthlyUsageUSD
float64
`json:"monthly_usage_usd"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
User
*
User
`json:"user,omitempty"`
Group
*
Group
`json:"group,omitempty"`
}
// AdminUserSubscription 是管理员接口使用的订阅 DTO(包含分配信息/备注等字段)。
// 注意:普通用户接口不得返回 assigned_by/assigned_at/notes/assigned_by_user 等管理员字段。
type
AdminUserSubscription
struct
{
UserSubscription
AssignedBy
*
int64
`json:"assigned_by"`
AssignedAt
time
.
Time
`json:"assigned_at"`
Notes
string
`json:"notes"`
CreatedAt
time
.
Time
`json:"created_at"`
UpdatedAt
time
.
Time
`json:"updated_at"`
User
*
User
`json:"user,omitempty"`
Group
*
Group
`json:"group,omitempty"`
AssignedByUser
*
User
`json:"assigned_by_user,omitempty"`
AssignedByUser
*
User
`json:"assigned_by_user,omitempty"`
}
type
BulkAssignResult
struct
{
SuccessCount
int
`json:"success_count"`
FailedCount
int
`json:"failed_count"`
Subscriptions
[]
UserSubscription
`json:"subscriptions"`
Errors
[]
string
`json:"errors"`
SuccessCount
int
`json:"success_count"`
FailedCount
int
`json:"failed_count"`
Subscriptions
[]
Admin
UserSubscription
`json:"subscriptions"`
Errors
[]
string
`json:"errors"`
}
// PromoCode 注册优惠码
...
...
backend/internal/handler/gateway_handler.go
View file @
2fe8932c
...
...
@@ -31,6 +31,8 @@ type GatewayHandler struct {
userService
*
service
.
UserService
billingCacheService
*
service
.
BillingCacheService
concurrencyHelper
*
ConcurrencyHelper
maxAccountSwitches
int
maxAccountSwitchesGemini
int
}
// NewGatewayHandler creates a new GatewayHandler
...
...
@@ -44,8 +46,16 @@ func NewGatewayHandler(
cfg
*
config
.
Config
,
)
*
GatewayHandler
{
pingInterval
:=
time
.
Duration
(
0
)
maxAccountSwitches
:=
10
maxAccountSwitchesGemini
:=
3
if
cfg
!=
nil
{
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
{
gatewayService
:
gatewayService
,
...
...
@@ -54,6 +64,8 @@ func NewGatewayHandler(
userService
:
userService
,
billingCacheService
:
billingCacheService
,
concurrencyHelper
:
NewConcurrencyHelper
(
concurrencyService
,
SSEPingFormatClaude
,
pingInterval
),
maxAccountSwitches
:
maxAccountSwitches
,
maxAccountSwitchesGemini
:
maxAccountSwitchesGemini
,
}
}
...
...
@@ -179,7 +191,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
if
platform
==
service
.
PlatformGemini
{
const
maxAccountSwitches
=
3
maxAccountSwitches
:=
h
.
maxAccountSwitches
Gemini
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
lastFailoverStatus
:=
0
...
...
@@ -197,17 +209,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
// 检查预热请求拦截(在账号选择后、转发前检查)
if
account
.
IsInterceptWarmupEnabled
()
&&
isWarmupRequest
(
body
)
{
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
if
reqStream
{
sendMockWarmupStream
(
c
,
reqModel
)
}
else
{
sendMockWarmupResponse
(
c
,
reqModel
)
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if
account
.
IsInterceptWarmupEnabled
()
{
interceptType
:=
detectInterceptType
(
body
)
if
interceptType
!=
InterceptTypeNone
{
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
if
reqStream
{
sendMockInterceptStream
(
c
,
reqModel
,
interceptType
)
}
else
{
sendMockInterceptResponse
(
c
,
reqModel
,
interceptType
)
}
return
}
return
}
// 3. 获取账号并发槽位
...
...
@@ -313,7 +328,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
const
maxAccountSwitches
=
10
maxAccountSwitches
:=
h
.
maxAccountSwitches
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
lastFailoverStatus
:=
0
...
...
@@ -332,17 +347,20 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
// 检查预热请求拦截(在账号选择后、转发前检查)
if
account
.
IsInterceptWarmupEnabled
()
&&
isWarmupRequest
(
body
)
{
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
if
reqStream
{
sendMockWarmupStream
(
c
,
reqModel
)
}
else
{
sendMockWarmupResponse
(
c
,
reqModel
)
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if
account
.
IsInterceptWarmupEnabled
()
{
interceptType
:=
detectInterceptType
(
body
)
if
interceptType
!=
InterceptTypeNone
{
if
selection
.
Acquired
&&
selection
.
ReleaseFunc
!=
nil
{
selection
.
ReleaseFunc
()
}
if
reqStream
{
sendMockInterceptStream
(
c
,
reqModel
,
interceptType
)
}
else
{
sendMockInterceptResponse
(
c
,
reqModel
,
interceptType
)
}
return
}
return
}
// 3. 获取账号并发槽位
...
...
@@ -756,17 +774,30 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
}
}
// isWarmupRequest 检测是否为预热请求(标题生成、Warmup等)
func
isWarmupRequest
(
body
[]
byte
)
bool
{
// 快速检查:如果body不包含关键字,直接返回false
// InterceptType 表示请求拦截类型
type
InterceptType
int
const
(
InterceptTypeNone
InterceptType
=
iota
InterceptTypeWarmup
// 预热请求(返回 "New Conversation")
InterceptTypeSuggestionMode
// SUGGESTION MODE(返回空字符串)
)
// detectInterceptType 检测请求是否需要拦截,返回拦截类型
func
detectInterceptType
(
body
[]
byte
)
InterceptType
{
// 快速检查:如果不包含任何关键字,直接返回
bodyStr
:=
string
(
body
)
if
!
strings
.
Contains
(
bodyStr
,
"title"
)
&&
!
strings
.
Contains
(
bodyStr
,
"Warmup"
)
{
return
false
hasSuggestionMode
:=
strings
.
Contains
(
bodyStr
,
"[SUGGESTION MODE:"
)
hasWarmupKeyword
:=
strings
.
Contains
(
bodyStr
,
"title"
)
||
strings
.
Contains
(
bodyStr
,
"Warmup"
)
if
!
hasSuggestionMode
&&
!
hasWarmupKeyword
{
return
InterceptTypeNone
}
// 解析
完整
请求
// 解析请求
(只解析一次)
var
req
struct
{
Messages
[]
struct
{
Role
string
`json:"role"`
Content
[]
struct
{
Type
string
`json:"type"`
Text
string
`json:"text"`
...
...
@@ -777,43 +808,71 @@ func isWarmupRequest(body []byte) bool {
}
`json:"system"`
}
if
err
:=
json
.
Unmarshal
(
body
,
&
req
);
err
!=
nil
{
return
fals
e
return
InterceptTypeNon
e
}
// 检查 messages 中的标题提示模式
for
_
,
msg
:=
range
req
.
Messages
{
for
_
,
content
:=
range
msg
.
Content
{
if
content
.
Type
==
"text"
{
if
strings
.
Contains
(
content
.
Text
,
"Please write a 5-10 word title for the following conversation:"
)
||
content
.
Text
==
"Warmup"
{
return
true
}
}
// 检查 SUGGESTION MODE(最后一条 user 消息)
if
hasSuggestionMode
&&
len
(
req
.
Messages
)
>
0
{
lastMsg
:=
req
.
Messages
[
len
(
req
.
Messages
)
-
1
]
if
lastMsg
.
Role
==
"user"
&&
len
(
lastMsg
.
Content
)
>
0
&&
lastMsg
.
Content
[
0
]
.
Type
==
"text"
&&
strings
.
HasPrefix
(
lastMsg
.
Content
[
0
]
.
Text
,
"[SUGGESTION MODE:"
)
{
return
InterceptTypeSuggestionMode
}
}
// 检查 system 中的标题提取模式
for
_
,
system
:=
range
req
.
System
{
if
strings
.
Contains
(
system
.
Text
,
"nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title"
)
{
return
true
// 检查 Warmup 请求
if
hasWarmupKeyword
{
// 检查 messages 中的标题提示模式
for
_
,
msg
:=
range
req
.
Messages
{
for
_
,
content
:=
range
msg
.
Content
{
if
content
.
Type
==
"text"
{
if
strings
.
Contains
(
content
.
Text
,
"Please write a 5-10 word title for the following conversation:"
)
||
content
.
Text
==
"Warmup"
{
return
InterceptTypeWarmup
}
}
}
}
// 检查 system 中的标题提取模式
for
_
,
sys
:=
range
req
.
System
{
if
strings
.
Contains
(
sys
.
Text
,
"nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title"
)
{
return
InterceptTypeWarmup
}
}
}
return
fals
e
return
InterceptTypeNon
e
}
// sendMock
Warmup
Stream 发送流式 mock 响应(用于
预热
请求拦截)
func
sendMock
Warmup
Stream
(
c
*
gin
.
Context
,
model
string
)
{
// sendMock
Intercept
Stream 发送流式 mock 响应(用于请求拦截)
func
sendMock
Intercept
Stream
(
c
*
gin
.
Context
,
model
string
,
interceptType
InterceptType
)
{
c
.
Header
(
"Content-Type"
,
"text/event-stream"
)
c
.
Header
(
"Cache-Control"
,
"no-cache"
)
c
.
Header
(
"Connection"
,
"keep-alive"
)
c
.
Header
(
"X-Accel-Buffering"
,
"no"
)
// 根据拦截类型决定响应内容
var
msgID
string
var
outputTokens
int
var
textDeltas
[]
string
switch
interceptType
{
case
InterceptTypeSuggestionMode
:
msgID
=
"msg_mock_suggestion"
outputTokens
=
1
textDeltas
=
[]
string
{
""
}
// 空内容
default
:
// InterceptTypeWarmup
msgID
=
"msg_mock_warmup"
outputTokens
=
2
textDeltas
=
[]
string
{
"New"
,
" Conversation"
}
}
// Build message_start event with proper JSON marshaling
messageStart
:=
map
[
string
]
any
{
"type"
:
"message_start"
,
"message"
:
map
[
string
]
any
{
"id"
:
"
msg
_mock_warmup"
,
"id"
:
msg
ID
,
"type"
:
"message"
,
"role"
:
"assistant"
,
"model"
:
model
,
...
...
@@ -828,16 +887,46 @@ func sendMockWarmupStream(c *gin.Context, model string) {
}
messageStartJSON
,
_
:=
json
.
Marshal
(
messageStart
)
// Build events
events
:=
[]
string
{
`event: message_start`
+
"
\n
"
+
`data: `
+
string
(
messageStartJSON
),
`event: content_block_start`
+
"
\n
"
+
`data: {"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"}`
,
`event: content_block_delta`
+
"
\n
"
+
`data: {"delta":{"text":"New","type":"text_delta"},"index":0,"type":"content_block_delta"}`
,
`event: content_block_delta`
+
"
\n
"
+
`data: {"delta":{"text":" Conversation","type":"text_delta"},"index":0,"type":"content_block_delta"}`
,
`event: content_block_stop`
+
"
\n
"
+
`data: {"index":0,"type":"content_block_stop"}`
,
`event: message_delta`
+
"
\n
"
+
`data: {"delta":{"stop_reason":"end_turn","stop_sequence":null},"type":"message_delta","usage":{"input_tokens":10,"output_tokens":2}}`
,
`event: message_stop`
+
"
\n
"
+
`data: {"type":"message_stop"}`
,
}
// Add text deltas
for
_
,
text
:=
range
textDeltas
{
delta
:=
map
[
string
]
any
{
"type"
:
"content_block_delta"
,
"index"
:
0
,
"delta"
:
map
[
string
]
string
{
"type"
:
"text_delta"
,
"text"
:
text
,
},
}
deltaJSON
,
_
:=
json
.
Marshal
(
delta
)
events
=
append
(
events
,
`event: content_block_delta`
+
"
\n
"
+
`data: `
+
string
(
deltaJSON
))
}
// Add final events
messageDelta
:=
map
[
string
]
any
{
"type"
:
"message_delta"
,
"delta"
:
map
[
string
]
any
{
"stop_reason"
:
"end_turn"
,
"stop_sequence"
:
nil
,
},
"usage"
:
map
[
string
]
int
{
"input_tokens"
:
10
,
"output_tokens"
:
outputTokens
,
},
}
messageDeltaJSON
,
_
:=
json
.
Marshal
(
messageDelta
)
events
=
append
(
events
,
`event: content_block_stop`
+
"
\n
"
+
`data: {"index":0,"type":"content_block_stop"}`
,
`event: message_delta`
+
"
\n
"
+
`data: `
+
string
(
messageDeltaJSON
),
`event: message_stop`
+
"
\n
"
+
`data: {"type":"message_stop"}`
,
)
for
_
,
event
:=
range
events
{
_
,
_
=
c
.
Writer
.
WriteString
(
event
+
"
\n\n
"
)
c
.
Writer
.
Flush
()
...
...
@@ -845,18 +934,32 @@ func sendMockWarmupStream(c *gin.Context, model string) {
}
}
// sendMockWarmupResponse 发送非流式 mock 响应(用于预热请求拦截)
func
sendMockWarmupResponse
(
c
*
gin
.
Context
,
model
string
)
{
// sendMockInterceptResponse 发送非流式 mock 响应(用于请求拦截)
func
sendMockInterceptResponse
(
c
*
gin
.
Context
,
model
string
,
interceptType
InterceptType
)
{
var
msgID
,
text
string
var
outputTokens
int
switch
interceptType
{
case
InterceptTypeSuggestionMode
:
msgID
=
"msg_mock_suggestion"
text
=
""
outputTokens
=
1
default
:
// InterceptTypeWarmup
msgID
=
"msg_mock_warmup"
text
=
"New Conversation"
outputTokens
=
2
}
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"id"
:
"
msg
_mock_warmup"
,
"id"
:
msg
ID
,
"type"
:
"message"
,
"role"
:
"assistant"
,
"model"
:
model
,
"content"
:
[]
gin
.
H
{{
"type"
:
"text"
,
"text"
:
"New Conversation"
}},
"content"
:
[]
gin
.
H
{{
"type"
:
"text"
,
"text"
:
text
}},
"stop_reason"
:
"end_turn"
,
"usage"
:
gin
.
H
{
"input_tokens"
:
10
,
"output_tokens"
:
2
,
"output_tokens"
:
outputTokens
,
},
})
}
...
...
backend/internal/handler/gemini_cli_session_test.go
0 → 100644
View file @
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
import
(
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log"
"net/http"
"regexp"
"strings"
"time"
...
...
@@ -19,6 +23,17 @@ import (
"github.com/gin-gonic/gin"
)
// geminiCLITmpDirRegex 用于从 Gemini CLI 请求体中提取 tmp 目录的哈希值
// 匹配格式: /Users/xxx/.gemini/tmp/[64位十六进制哈希]
var
geminiCLITmpDirRegex
=
regexp
.
MustCompile
(
`/\.gemini/tmp/([A-Fa-f0-9]{64})`
)
func
isGeminiCLIRequest
(
c
*
gin
.
Context
,
body
[]
byte
)
bool
{
if
strings
.
TrimSpace
(
c
.
GetHeader
(
"x-gemini-api-privileged-user-id"
))
!=
""
{
return
true
}
return
geminiCLITmpDirRegex
.
Match
(
body
)
}
// GeminiV1BetaListModels proxies:
// GET /v1beta/models
func
(
h
*
GatewayHandler
)
GeminiV1BetaListModels
(
c
*
gin
.
Context
)
{
...
...
@@ -214,13 +229,27 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
}
// 3) select account (sticky session based on request body)
parsedReq
,
_
:=
service
.
ParseGatewayRequest
(
body
)
sessionHash
:=
h
.
gatewayService
.
GenerateSessionHash
(
parsedReq
)
// 优先使用 Gemini CLI 的会话标识(privileged-user-id + tmp 目录哈希)
sessionHash
:=
extractGeminiCLISessionHash
(
c
,
body
)
if
sessionHash
==
""
{
// Fallback: 使用通用的会话哈希生成逻辑(适用于其他客户端)
parsedReq
,
_
:=
service
.
ParseGatewayRequest
(
body
)
sessionHash
=
h
.
gatewayService
.
GenerateSessionHash
(
parsedReq
)
}
sessionKey
:=
sessionHash
if
sessionHash
!=
""
{
sessionKey
=
"gemini:"
+
sessionHash
}
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
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
lastFailoverStatus
:=
0
...
...
@@ -238,6 +267,24 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
// 检测账号切换:如果粘性会话绑定的账号与当前选择的账号不同,清除 thoughtSignature
// 注意:Gemini 原生 API 的 thoughtSignature 与具体上游账号强相关;跨账号透传会导致 400。
if
sessionBoundAccountID
>
0
&&
sessionBoundAccountID
!=
account
.
ID
{
log
.
Printf
(
"[Gemini] Sticky session account switched: %d -> %d, cleaning thoughtSignature"
,
sessionBoundAccountID
,
account
.
ID
)
body
=
service
.
CleanGeminiNativeThoughtSignatures
(
body
)
sessionBoundAccountID
=
account
.
ID
}
else
if
sessionKey
!=
""
&&
sessionBoundAccountID
==
0
&&
isCLI
&&
!
cleanedForUnknownBinding
&&
bytes
.
Contains
(
body
,
[]
byte
(
`"thoughtSignature"`
))
{
// 无缓存绑定但请求里已有 thoughtSignature:常见于缓存丢失/TTL 过期后,CLI 继续携带旧签名。
// 为避免第一次转发就 400,这里做一次确定性清理,让新账号重新生成签名链路。
log
.
Printf
(
"[Gemini] Sticky session binding missing for CLI request, cleaning thoughtSignature proactively"
)
body
=
service
.
CleanGeminiNativeThoughtSignatures
(
body
)
cleanedForUnknownBinding
=
true
sessionBoundAccountID
=
account
.
ID
}
else
if
sessionBoundAccountID
==
0
{
// 记录本次请求中首次选择到的账号,便于同一请求内 failover 时检测切换。
sessionBoundAccountID
=
account
.
ID
}
// 4) account concurrency slot
accountReleaseFunc
:=
selection
.
ReleaseFunc
if
!
selection
.
Acquired
{
...
...
@@ -433,3 +480,38 @@ func shouldFallbackGeminiModels(res *service.UpstreamHTTPResult) bool {
}
return
false
}
// extractGeminiCLISessionHash 从 Gemini CLI 请求中提取会话标识。
// 组合 x-gemini-api-privileged-user-id header 和请求体中的 tmp 目录哈希。
//
// 会话标识生成策略:
// 1. 从请求体中提取 tmp 目录哈希(64位十六进制)
// 2. 从 header 中提取 privileged-user-id(UUID)
// 3. 组合两者生成 SHA256 哈希作为最终的会话标识
//
// 如果找不到 tmp 目录哈希,返回空字符串(不使用粘性会话)。
//
// extractGeminiCLISessionHash extracts session identifier from Gemini CLI requests.
// Combines x-gemini-api-privileged-user-id header with tmp directory hash from request body.
func
extractGeminiCLISessionHash
(
c
*
gin
.
Context
,
body
[]
byte
)
string
{
// 1. 从请求体中提取 tmp 目录哈希
match
:=
geminiCLITmpDirRegex
.
FindSubmatch
(
body
)
if
len
(
match
)
<
2
{
return
""
// 没有找到 tmp 目录,不使用粘性会话
}
tmpDirHash
:=
string
(
match
[
1
])
// 2. 提取 privileged-user-id
privilegedUserID
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"x-gemini-api-privileged-user-id"
))
// 3. 组合生成最终的 session hash
if
privilegedUserID
!=
""
{
// 组合两个标识符:privileged-user-id + tmp 目录哈希
combined
:=
privilegedUserID
+
":"
+
tmpDirHash
hash
:=
sha256
.
Sum256
([]
byte
(
combined
))
return
hex
.
EncodeToString
(
hash
[
:
])
}
// 如果没有 privileged-user-id,直接使用 tmp 目录哈希
return
tmpDirHash
}
backend/internal/handler/handler.go
View file @
2fe8932c
...
...
@@ -37,6 +37,7 @@ type Handlers struct {
Gateway
*
GatewayHandler
OpenAIGateway
*
OpenAIGatewayHandler
Setting
*
SettingHandler
Totp
*
TotpHandler
}
// BuildInfo contains build-time information
...
...
backend/internal/handler/openai_gateway_handler.go
View file @
2fe8932c
...
...
@@ -25,6 +25,7 @@ type OpenAIGatewayHandler struct {
gatewayService
*
service
.
OpenAIGatewayService
billingCacheService
*
service
.
BillingCacheService
concurrencyHelper
*
ConcurrencyHelper
maxAccountSwitches
int
}
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
...
...
@@ -35,13 +36,18 @@ func NewOpenAIGatewayHandler(
cfg
*
config
.
Config
,
)
*
OpenAIGatewayHandler
{
pingInterval
:=
time
.
Duration
(
0
)
maxAccountSwitches
:=
3
if
cfg
!=
nil
{
pingInterval
=
time
.
Duration
(
cfg
.
Concurrency
.
PingInterval
)
*
time
.
Second
if
cfg
.
Gateway
.
MaxAccountSwitches
>
0
{
maxAccountSwitches
=
cfg
.
Gateway
.
MaxAccountSwitches
}
}
return
&
OpenAIGatewayHandler
{
gatewayService
:
gatewayService
,
billingCacheService
:
billingCacheService
,
concurrencyHelper
:
NewConcurrencyHelper
(
concurrencyService
,
SSEPingFormatComment
,
pingInterval
),
maxAccountSwitches
:
maxAccountSwitches
,
}
}
...
...
@@ -186,10 +192,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
}
// Generate session hash (
from
header f
or OpenAI
)
sessionHash
:=
h
.
gatewayService
.
GenerateSessionHash
(
c
)
// Generate session hash (header f
irst; fallback to prompt_cache_key
)
sessionHash
:=
h
.
gatewayService
.
GenerateSessionHash
(
c
,
reqBody
)
const
maxAccountSwitches
=
3
maxAccountSwitches
:=
h
.
maxAccountSwitches
switchCount
:=
0
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
lastFailoverStatus
:=
0
...
...
backend/internal/handler/setting_handler.go
View file @
2fe8932c
...
...
@@ -32,18 +32,24 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
}
response
.
Success
(
c
,
dto
.
PublicSettings
{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
TurnstileEnabled
:
settings
.
TurnstileEnabled
,
TurnstileSiteKey
:
settings
.
TurnstileSiteKey
,
SiteName
:
settings
.
SiteName
,
SiteLogo
:
settings
.
SiteLogo
,
SiteSubtitle
:
settings
.
SiteSubtitle
,
APIBaseURL
:
settings
.
APIBaseURL
,
ContactInfo
:
settings
.
ContactInfo
,
DocURL
:
settings
.
DocURL
,
HomeContent
:
settings
.
HomeContent
,
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
Version
:
h
.
version
,
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
PromoCodeEnabled
:
settings
.
PromoCodeEnabled
,
PasswordResetEnabled
:
settings
.
PasswordResetEnabled
,
TotpEnabled
:
settings
.
TotpEnabled
,
TurnstileEnabled
:
settings
.
TurnstileEnabled
,
TurnstileSiteKey
:
settings
.
TurnstileSiteKey
,
SiteName
:
settings
.
SiteName
,
SiteLogo
:
settings
.
SiteLogo
,
SiteSubtitle
:
settings
.
SiteSubtitle
,
APIBaseURL
:
settings
.
APIBaseURL
,
ContactInfo
:
settings
.
ContactInfo
,
DocURL
:
settings
.
DocURL
,
HomeContent
:
settings
.
HomeContent
,
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
Version
:
h
.
version
,
})
}
backend/internal/handler/totp_handler.go
0 → 100644
View file @
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