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
a04ae28a
Commit
a04ae28a
authored
Apr 13, 2026
by
陈曦
Browse files
merge v0.1.111
parents
68f67198
ad64190b
Changes
302
Show whitespace changes
Inline
Side-by-side
Too many changes to show.
To preserve performance only
302 of 302+
files are displayed.
Plain diff
Email patch
backend/internal/handler/admin/proxy_data_handler_test.go
View file @
a04ae28a
...
...
@@ -74,6 +74,10 @@ func TestProxyExportDataRespectsFilters(t *testing.T) {
require
.
Len
(
t
,
resp
.
Data
.
Proxies
,
1
)
require
.
Len
(
t
,
resp
.
Data
.
Accounts
,
0
)
require
.
Equal
(
t
,
"https"
,
resp
.
Data
.
Proxies
[
0
]
.
Protocol
)
require
.
Equal
(
t
,
1
,
adminSvc
.
lastListProxies
.
calls
)
require
.
Equal
(
t
,
"https"
,
adminSvc
.
lastListProxies
.
protocol
)
require
.
Equal
(
t
,
"id"
,
adminSvc
.
lastListProxies
.
sortBy
)
require
.
Equal
(
t
,
"desc"
,
adminSvc
.
lastListProxies
.
sortOrder
)
}
func
TestProxyExportDataWithSelectedIDs
(
t
*
testing
.
T
)
{
...
...
@@ -113,6 +117,96 @@ func TestProxyExportDataWithSelectedIDs(t *testing.T) {
require
.
Len
(
t
,
resp
.
Data
.
Proxies
,
1
)
require
.
Equal
(
t
,
"https"
,
resp
.
Data
.
Proxies
[
0
]
.
Protocol
)
require
.
Equal
(
t
,
"10.0.0.2"
,
resp
.
Data
.
Proxies
[
0
]
.
Host
)
require
.
Equal
(
t
,
0
,
adminSvc
.
lastListProxies
.
calls
)
}
func
TestProxyExportDataPassesSortParams
(
t
*
testing
.
T
)
{
router
,
adminSvc
:=
setupProxyDataRouter
()
adminSvc
.
proxies
=
[]
service
.
Proxy
{
{
ID
:
1
,
Name
:
"proxy-a"
,
Protocol
:
"http"
,
Host
:
"127.0.0.1"
,
Port
:
8080
,
Username
:
"user"
,
Password
:
"pass"
,
Status
:
service
.
StatusActive
,
},
}
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/proxies/data?protocol=http&status=active&search=proxy&sort_by=name&sort_order=asc"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
1
,
adminSvc
.
lastListProxies
.
calls
)
require
.
Equal
(
t
,
"http"
,
adminSvc
.
lastListProxies
.
protocol
)
require
.
Equal
(
t
,
"active"
,
adminSvc
.
lastListProxies
.
status
)
require
.
Equal
(
t
,
"proxy"
,
adminSvc
.
lastListProxies
.
search
)
require
.
Equal
(
t
,
"name"
,
adminSvc
.
lastListProxies
.
sortBy
)
require
.
Equal
(
t
,
"asc"
,
adminSvc
.
lastListProxies
.
sortOrder
)
}
func
TestProxyExportDataSortByAccountCountUsesAccountCountListing
(
t
*
testing
.
T
)
{
router
,
adminSvc
:=
setupProxyDataRouter
()
adminSvc
.
proxies
=
[]
service
.
Proxy
{
{
ID
:
1
,
Name
:
"proxy-id-1"
,
Protocol
:
"http"
,
Host
:
"127.0.0.1"
,
Port
:
8080
,
Status
:
service
.
StatusActive
,
},
{
ID
:
2
,
Name
:
"proxy-id-2"
,
Protocol
:
"http"
,
Host
:
"127.0.0.2"
,
Port
:
8081
,
Status
:
service
.
StatusActive
,
},
}
adminSvc
.
proxyCounts
=
[]
service
.
ProxyWithAccountCount
{
{
Proxy
:
service
.
Proxy
{
ID
:
2
,
Name
:
"proxy-count-high"
,
Protocol
:
"http"
,
Host
:
"127.0.0.2"
,
Port
:
8081
,
Status
:
service
.
StatusActive
,
},
AccountCount
:
9
,
},
{
Proxy
:
service
.
Proxy
{
ID
:
1
,
Name
:
"proxy-count-low"
,
Protocol
:
"http"
,
Host
:
"127.0.0.1"
,
Port
:
8080
,
Status
:
service
.
StatusActive
,
},
AccountCount
:
1
,
},
}
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/proxies/data?sort_by=account_count&sort_order=desc"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
var
resp
proxyDataResponse
require
.
NoError
(
t
,
json
.
Unmarshal
(
rec
.
Body
.
Bytes
(),
&
resp
))
require
.
Equal
(
t
,
0
,
resp
.
Code
)
require
.
Len
(
t
,
resp
.
Data
.
Proxies
,
2
)
require
.
Equal
(
t
,
"proxy-count-high"
,
resp
.
Data
.
Proxies
[
0
]
.
Name
)
require
.
Equal
(
t
,
"proxy-count-low"
,
resp
.
Data
.
Proxies
[
1
]
.
Name
)
require
.
Equal
(
t
,
0
,
adminSvc
.
lastListProxies
.
calls
)
}
func
TestProxyImportDataReusesAndTriggersLatencyProbe
(
t
*
testing
.
T
)
{
...
...
backend/internal/handler/admin/proxy_handler.go
View file @
a04ae28a
...
...
@@ -52,13 +52,15 @@ func (h *ProxyHandler) List(c *gin.Context) {
protocol
:=
c
.
Query
(
"protocol"
)
status
:=
c
.
Query
(
"status"
)
search
:=
c
.
Query
(
"search"
)
sortBy
:=
c
.
DefaultQuery
(
"sort_by"
,
"id"
)
sortOrder
:=
c
.
DefaultQuery
(
"sort_order"
,
"desc"
)
// 标准化和验证 search 参数
search
=
strings
.
TrimSpace
(
search
)
if
len
(
search
)
>
100
{
search
=
search
[
:
100
]
}
proxies
,
total
,
err
:=
h
.
adminService
.
ListProxiesWithAccountCount
(
c
.
Request
.
Context
(),
page
,
pageSize
,
protocol
,
status
,
search
)
proxies
,
total
,
err
:=
h
.
adminService
.
ListProxiesWithAccountCount
(
c
.
Request
.
Context
(),
page
,
pageSize
,
protocol
,
status
,
search
,
sortBy
,
sortOrder
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
...
...
backend/internal/handler/admin/redeem_export_handler_test.go
0 → 100644
View file @
a04ae28a
package
admin
import
(
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func
setupRedeemExportRouter
()
(
*
gin
.
Engine
,
*
stubAdminService
)
{
gin
.
SetMode
(
gin
.
TestMode
)
router
:=
gin
.
New
()
adminSvc
:=
newStubAdminService
()
h
:=
NewRedeemHandler
(
adminSvc
,
nil
)
router
.
GET
(
"/api/v1/admin/redeem-codes/export"
,
h
.
Export
)
return
router
,
adminSvc
}
func
TestRedeemExportPassesSearchAndSort
(
t
*
testing
.
T
)
{
router
,
adminSvc
:=
setupRedeemExportRouter
()
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/redeem-codes/export?type=balance&status=unused&search=ABC&sort_by=value&sort_order=asc"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
1
,
adminSvc
.
lastListRedeemCodes
.
calls
)
require
.
Equal
(
t
,
"balance"
,
adminSvc
.
lastListRedeemCodes
.
codeType
)
require
.
Equal
(
t
,
"unused"
,
adminSvc
.
lastListRedeemCodes
.
status
)
require
.
Equal
(
t
,
"ABC"
,
adminSvc
.
lastListRedeemCodes
.
search
)
require
.
Equal
(
t
,
"value"
,
adminSvc
.
lastListRedeemCodes
.
sortBy
)
require
.
Equal
(
t
,
"asc"
,
adminSvc
.
lastListRedeemCodes
.
sortOrder
)
}
func
TestRedeemExportSortDefaults
(
t
*
testing
.
T
)
{
router
,
adminSvc
:=
setupRedeemExportRouter
()
rec
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/api/v1/admin/redeem-codes/export"
,
nil
)
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
1
,
adminSvc
.
lastListRedeemCodes
.
calls
)
require
.
Equal
(
t
,
"id"
,
adminSvc
.
lastListRedeemCodes
.
sortBy
)
require
.
Equal
(
t
,
"desc"
,
adminSvc
.
lastListRedeemCodes
.
sortOrder
)
}
backend/internal/handler/admin/redeem_handler.go
View file @
a04ae28a
...
...
@@ -59,13 +59,15 @@ func (h *RedeemHandler) List(c *gin.Context) {
codeType
:=
c
.
Query
(
"type"
)
status
:=
c
.
Query
(
"status"
)
search
:=
c
.
Query
(
"search"
)
sortBy
:=
c
.
DefaultQuery
(
"sort_by"
,
"id"
)
sortOrder
:=
c
.
DefaultQuery
(
"sort_order"
,
"desc"
)
// 标准化和验证 search 参数
search
=
strings
.
TrimSpace
(
search
)
if
len
(
search
)
>
100
{
search
=
search
[
:
100
]
}
codes
,
total
,
err
:=
h
.
adminService
.
ListRedeemCodes
(
c
.
Request
.
Context
(),
page
,
pageSize
,
codeType
,
status
,
search
)
codes
,
total
,
err
:=
h
.
adminService
.
ListRedeemCodes
(
c
.
Request
.
Context
(),
page
,
pageSize
,
codeType
,
status
,
search
,
sortBy
,
sortOrder
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
...
...
@@ -300,9 +302,15 @@ func (h *RedeemHandler) GetStats(c *gin.Context) {
func
(
h
*
RedeemHandler
)
Export
(
c
*
gin
.
Context
)
{
codeType
:=
c
.
Query
(
"type"
)
status
:=
c
.
Query
(
"status"
)
search
:=
strings
.
TrimSpace
(
c
.
Query
(
"search"
))
sortBy
:=
c
.
DefaultQuery
(
"sort_by"
,
"id"
)
sortOrder
:=
c
.
DefaultQuery
(
"sort_order"
,
"desc"
)
if
len
(
search
)
>
100
{
search
=
search
[
:
100
]
}
// Get all codes without pagination (use large page size)
codes
,
_
,
err
:=
h
.
adminService
.
ListRedeemCodes
(
c
.
Request
.
Context
(),
1
,
10000
,
codeType
,
status
,
""
)
codes
,
_
,
err
:=
h
.
adminService
.
ListRedeemCodes
(
c
.
Request
.
Context
(),
1
,
10000
,
codeType
,
status
,
search
,
sortBy
,
sortOrder
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
...
...
backend/internal/handler/admin/setting_handler.go
View file @
a04ae28a
...
...
@@ -35,21 +35,34 @@ func generateMenuItemID() (string, error) {
return
hex
.
EncodeToString
(
b
),
nil
}
func
scopesContainOpenID
(
scopes
string
)
bool
{
for
_
,
scope
:=
range
strings
.
Fields
(
strings
.
ToLower
(
strings
.
TrimSpace
(
scopes
)))
{
if
scope
==
"openid"
{
return
true
}
}
return
false
}
// SettingHandler 系统设置处理器
type
SettingHandler
struct
{
settingService
*
service
.
SettingService
emailService
*
service
.
EmailService
turnstileService
*
service
.
TurnstileService
opsService
*
service
.
OpsService
paymentConfigService
*
service
.
PaymentConfigService
paymentService
*
service
.
PaymentService
}
// NewSettingHandler 创建系统设置处理器
func
NewSettingHandler
(
settingService
*
service
.
SettingService
,
emailService
*
service
.
EmailService
,
turnstileService
*
service
.
TurnstileService
,
opsService
*
service
.
OpsService
)
*
SettingHandler
{
func
NewSettingHandler
(
settingService
*
service
.
SettingService
,
emailService
*
service
.
EmailService
,
turnstileService
*
service
.
TurnstileService
,
opsService
*
service
.
OpsService
,
paymentConfigService
*
service
.
PaymentConfigService
,
paymentService
*
service
.
PaymentService
)
*
SettingHandler
{
return
&
SettingHandler
{
settingService
:
settingService
,
emailService
:
emailService
,
turnstileService
:
turnstileService
,
opsService
:
opsService
,
paymentConfigService
:
paymentConfigService
,
paymentService
:
paymentService
,
}
}
...
...
@@ -72,6 +85,15 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
})
}
// Load payment config
var
paymentCfg
*
service
.
PaymentConfig
if
h
.
paymentConfigService
!=
nil
{
paymentCfg
,
_
=
h
.
paymentConfigService
.
GetPaymentConfig
(
c
.
Request
.
Context
())
}
if
paymentCfg
==
nil
{
paymentCfg
=
&
service
.
PaymentConfig
{}
}
response
.
Success
(
c
,
dto
.
SystemSettings
{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
settings
.
EmailVerifyEnabled
,
...
...
@@ -96,6 +118,28 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
LinuxDoConnectClientID
:
settings
.
LinuxDoConnectClientID
,
LinuxDoConnectClientSecretConfigured
:
settings
.
LinuxDoConnectClientSecretConfigured
,
LinuxDoConnectRedirectURL
:
settings
.
LinuxDoConnectRedirectURL
,
OIDCConnectEnabled
:
settings
.
OIDCConnectEnabled
,
OIDCConnectProviderName
:
settings
.
OIDCConnectProviderName
,
OIDCConnectClientID
:
settings
.
OIDCConnectClientID
,
OIDCConnectClientSecretConfigured
:
settings
.
OIDCConnectClientSecretConfigured
,
OIDCConnectIssuerURL
:
settings
.
OIDCConnectIssuerURL
,
OIDCConnectDiscoveryURL
:
settings
.
OIDCConnectDiscoveryURL
,
OIDCConnectAuthorizeURL
:
settings
.
OIDCConnectAuthorizeURL
,
OIDCConnectTokenURL
:
settings
.
OIDCConnectTokenURL
,
OIDCConnectUserInfoURL
:
settings
.
OIDCConnectUserInfoURL
,
OIDCConnectJWKSURL
:
settings
.
OIDCConnectJWKSURL
,
OIDCConnectScopes
:
settings
.
OIDCConnectScopes
,
OIDCConnectRedirectURL
:
settings
.
OIDCConnectRedirectURL
,
OIDCConnectFrontendRedirectURL
:
settings
.
OIDCConnectFrontendRedirectURL
,
OIDCConnectTokenAuthMethod
:
settings
.
OIDCConnectTokenAuthMethod
,
OIDCConnectUsePKCE
:
settings
.
OIDCConnectUsePKCE
,
OIDCConnectValidateIDToken
:
settings
.
OIDCConnectValidateIDToken
,
OIDCConnectAllowedSigningAlgs
:
settings
.
OIDCConnectAllowedSigningAlgs
,
OIDCConnectClockSkewSeconds
:
settings
.
OIDCConnectClockSkewSeconds
,
OIDCConnectRequireEmailVerified
:
settings
.
OIDCConnectRequireEmailVerified
,
OIDCConnectUserInfoEmailPath
:
settings
.
OIDCConnectUserInfoEmailPath
,
OIDCConnectUserInfoIDPath
:
settings
.
OIDCConnectUserInfoIDPath
,
OIDCConnectUserInfoUsernamePath
:
settings
.
OIDCConnectUserInfoUsernamePath
,
SiteName
:
settings
.
SiteName
,
SiteLogo
:
settings
.
SiteLogo
,
SiteSubtitle
:
settings
.
SiteSubtitle
,
...
...
@@ -106,6 +150,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
TableDefaultPageSize
:
settings
.
TableDefaultPageSize
,
TablePageSizeOptions
:
settings
.
TablePageSizeOptions
,
CustomMenuItems
:
dto
.
ParseCustomMenuItems
(
settings
.
CustomMenuItems
),
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
settings
.
CustomEndpoints
),
DefaultConcurrency
:
settings
.
DefaultConcurrency
,
...
...
@@ -129,6 +175,24 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EnableFingerprintUnification
:
settings
.
EnableFingerprintUnification
,
EnableMetadataPassthrough
:
settings
.
EnableMetadataPassthrough
,
EnableCCHSigning
:
settings
.
EnableCCHSigning
,
PaymentEnabled
:
paymentCfg
.
Enabled
,
PaymentMinAmount
:
paymentCfg
.
MinAmount
,
PaymentMaxAmount
:
paymentCfg
.
MaxAmount
,
PaymentDailyLimit
:
paymentCfg
.
DailyLimit
,
PaymentOrderTimeoutMin
:
paymentCfg
.
OrderTimeoutMin
,
PaymentMaxPendingOrders
:
paymentCfg
.
MaxPendingOrders
,
PaymentEnabledTypes
:
paymentCfg
.
EnabledTypes
,
PaymentBalanceDisabled
:
paymentCfg
.
BalanceDisabled
,
PaymentLoadBalanceStrat
:
paymentCfg
.
LoadBalanceStrategy
,
PaymentProductNamePrefix
:
paymentCfg
.
ProductNamePrefix
,
PaymentProductNameSuffix
:
paymentCfg
.
ProductNameSuffix
,
PaymentHelpImageURL
:
paymentCfg
.
HelpImageURL
,
PaymentHelpText
:
paymentCfg
.
HelpText
,
PaymentCancelRateLimitEnabled
:
paymentCfg
.
CancelRateLimitEnabled
,
PaymentCancelRateLimitMax
:
paymentCfg
.
CancelRateLimitMax
,
PaymentCancelRateLimitWindow
:
paymentCfg
.
CancelRateLimitWindow
,
PaymentCancelRateLimitUnit
:
paymentCfg
.
CancelRateLimitUnit
,
PaymentCancelRateLimitMode
:
paymentCfg
.
CancelRateLimitMode
,
})
}
...
...
@@ -164,6 +228,30 @@ type UpdateSettingsRequest struct {
LinuxDoConnectClientSecret
string
`json:"linuxdo_connect_client_secret"`
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
// Generic OIDC OAuth 登录
OIDCConnectEnabled
bool
`json:"oidc_connect_enabled"`
OIDCConnectProviderName
string
`json:"oidc_connect_provider_name"`
OIDCConnectClientID
string
`json:"oidc_connect_client_id"`
OIDCConnectClientSecret
string
`json:"oidc_connect_client_secret"`
OIDCConnectIssuerURL
string
`json:"oidc_connect_issuer_url"`
OIDCConnectDiscoveryURL
string
`json:"oidc_connect_discovery_url"`
OIDCConnectAuthorizeURL
string
`json:"oidc_connect_authorize_url"`
OIDCConnectTokenURL
string
`json:"oidc_connect_token_url"`
OIDCConnectUserInfoURL
string
`json:"oidc_connect_userinfo_url"`
OIDCConnectJWKSURL
string
`json:"oidc_connect_jwks_url"`
OIDCConnectScopes
string
`json:"oidc_connect_scopes"`
OIDCConnectRedirectURL
string
`json:"oidc_connect_redirect_url"`
OIDCConnectFrontendRedirectURL
string
`json:"oidc_connect_frontend_redirect_url"`
OIDCConnectTokenAuthMethod
string
`json:"oidc_connect_token_auth_method"`
OIDCConnectUsePKCE
bool
`json:"oidc_connect_use_pkce"`
OIDCConnectValidateIDToken
bool
`json:"oidc_connect_validate_id_token"`
OIDCConnectAllowedSigningAlgs
string
`json:"oidc_connect_allowed_signing_algs"`
OIDCConnectClockSkewSeconds
int
`json:"oidc_connect_clock_skew_seconds"`
OIDCConnectRequireEmailVerified
bool
`json:"oidc_connect_require_email_verified"`
OIDCConnectUserInfoEmailPath
string
`json:"oidc_connect_userinfo_email_path"`
OIDCConnectUserInfoIDPath
string
`json:"oidc_connect_userinfo_id_path"`
OIDCConnectUserInfoUsernamePath
string
`json:"oidc_connect_userinfo_username_path"`
// OEM设置
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
...
...
@@ -175,6 +263,8 @@ type UpdateSettingsRequest struct {
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
*
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
*
string
`json:"purchase_subscription_url"`
TableDefaultPageSize
int
`json:"table_default_page_size"`
TablePageSizeOptions
[]
int
`json:"table_page_size_options"`
CustomMenuItems
*
[]
dto
.
CustomMenuItem
`json:"custom_menu_items"`
CustomEndpoints
*
[]
dto
.
CustomEndpoint
`json:"custom_endpoints"`
...
...
@@ -213,6 +303,28 @@ type UpdateSettingsRequest struct {
EnableFingerprintUnification
*
bool
`json:"enable_fingerprint_unification"`
EnableMetadataPassthrough
*
bool
`json:"enable_metadata_passthrough"`
EnableCCHSigning
*
bool
`json:"enable_cch_signing"`
// Payment configuration (integrated into settings, full replace)
PaymentEnabled
*
bool
`json:"payment_enabled"`
PaymentMinAmount
*
float64
`json:"payment_min_amount"`
PaymentMaxAmount
*
float64
`json:"payment_max_amount"`
PaymentDailyLimit
*
float64
`json:"payment_daily_limit"`
PaymentOrderTimeoutMin
*
int
`json:"payment_order_timeout_minutes"`
PaymentMaxPendingOrders
*
int
`json:"payment_max_pending_orders"`
PaymentEnabledTypes
[]
string
`json:"payment_enabled_types"`
PaymentBalanceDisabled
*
bool
`json:"payment_balance_disabled"`
PaymentLoadBalanceStrat
*
string
`json:"payment_load_balance_strategy"`
PaymentProductNamePrefix
*
string
`json:"payment_product_name_prefix"`
PaymentProductNameSuffix
*
string
`json:"payment_product_name_suffix"`
PaymentHelpImageURL
*
string
`json:"payment_help_image_url"`
PaymentHelpText
*
string
`json:"payment_help_text"`
// Cancel rate limit
PaymentCancelRateLimitEnabled
*
bool
`json:"payment_cancel_rate_limit_enabled"`
PaymentCancelRateLimitMax
*
int
`json:"payment_cancel_rate_limit_max"`
PaymentCancelRateLimitWindow
*
int
`json:"payment_cancel_rate_limit_window"`
PaymentCancelRateLimitUnit
*
string
`json:"payment_cancel_rate_limit_unit"`
PaymentCancelRateLimitMode
*
string
`json:"payment_cancel_rate_limit_window_mode"`
}
// UpdateSettings 更新系统设置
...
...
@@ -237,6 +349,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if
req
.
DefaultBalance
<
0
{
req
.
DefaultBalance
=
0
}
// 通用表格配置:兼容旧客户端未传字段时保留当前值。
if
req
.
TableDefaultPageSize
<=
0
{
req
.
TableDefaultPageSize
=
previousSettings
.
TableDefaultPageSize
}
if
req
.
TablePageSizeOptions
==
nil
{
req
.
TablePageSizeOptions
=
previousSettings
.
TablePageSizeOptions
}
req
.
SMTPHost
=
strings
.
TrimSpace
(
req
.
SMTPHost
)
req
.
SMTPUsername
=
strings
.
TrimSpace
(
req
.
SMTPUsername
)
req
.
SMTPPassword
=
strings
.
TrimSpace
(
req
.
SMTPPassword
)
...
...
@@ -324,6 +443,122 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
// Generic OIDC 参数验证
if
req
.
OIDCConnectEnabled
{
req
.
OIDCConnectProviderName
=
strings
.
TrimSpace
(
req
.
OIDCConnectProviderName
)
req
.
OIDCConnectClientID
=
strings
.
TrimSpace
(
req
.
OIDCConnectClientID
)
req
.
OIDCConnectClientSecret
=
strings
.
TrimSpace
(
req
.
OIDCConnectClientSecret
)
req
.
OIDCConnectIssuerURL
=
strings
.
TrimSpace
(
req
.
OIDCConnectIssuerURL
)
req
.
OIDCConnectDiscoveryURL
=
strings
.
TrimSpace
(
req
.
OIDCConnectDiscoveryURL
)
req
.
OIDCConnectAuthorizeURL
=
strings
.
TrimSpace
(
req
.
OIDCConnectAuthorizeURL
)
req
.
OIDCConnectTokenURL
=
strings
.
TrimSpace
(
req
.
OIDCConnectTokenURL
)
req
.
OIDCConnectUserInfoURL
=
strings
.
TrimSpace
(
req
.
OIDCConnectUserInfoURL
)
req
.
OIDCConnectJWKSURL
=
strings
.
TrimSpace
(
req
.
OIDCConnectJWKSURL
)
req
.
OIDCConnectScopes
=
strings
.
TrimSpace
(
req
.
OIDCConnectScopes
)
req
.
OIDCConnectRedirectURL
=
strings
.
TrimSpace
(
req
.
OIDCConnectRedirectURL
)
req
.
OIDCConnectFrontendRedirectURL
=
strings
.
TrimSpace
(
req
.
OIDCConnectFrontendRedirectURL
)
req
.
OIDCConnectTokenAuthMethod
=
strings
.
ToLower
(
strings
.
TrimSpace
(
req
.
OIDCConnectTokenAuthMethod
))
req
.
OIDCConnectAllowedSigningAlgs
=
strings
.
TrimSpace
(
req
.
OIDCConnectAllowedSigningAlgs
)
req
.
OIDCConnectUserInfoEmailPath
=
strings
.
TrimSpace
(
req
.
OIDCConnectUserInfoEmailPath
)
req
.
OIDCConnectUserInfoIDPath
=
strings
.
TrimSpace
(
req
.
OIDCConnectUserInfoIDPath
)
req
.
OIDCConnectUserInfoUsernamePath
=
strings
.
TrimSpace
(
req
.
OIDCConnectUserInfoUsernamePath
)
if
req
.
OIDCConnectProviderName
==
""
{
req
.
OIDCConnectProviderName
=
"OIDC"
}
if
req
.
OIDCConnectClientID
==
""
{
response
.
BadRequest
(
c
,
"OIDC Client ID is required when enabled"
)
return
}
if
req
.
OIDCConnectIssuerURL
==
""
{
response
.
BadRequest
(
c
,
"OIDC Issuer URL is required when enabled"
)
return
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
req
.
OIDCConnectIssuerURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"OIDC Issuer URL must be an absolute http(s) URL"
)
return
}
if
req
.
OIDCConnectDiscoveryURL
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
req
.
OIDCConnectDiscoveryURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"OIDC Discovery URL must be an absolute http(s) URL"
)
return
}
}
if
req
.
OIDCConnectAuthorizeURL
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
req
.
OIDCConnectAuthorizeURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"OIDC Authorize URL must be an absolute http(s) URL"
)
return
}
}
if
req
.
OIDCConnectTokenURL
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
req
.
OIDCConnectTokenURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"OIDC Token URL must be an absolute http(s) URL"
)
return
}
}
if
req
.
OIDCConnectUserInfoURL
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
req
.
OIDCConnectUserInfoURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"OIDC UserInfo URL must be an absolute http(s) URL"
)
return
}
}
if
req
.
OIDCConnectRedirectURL
==
""
{
response
.
BadRequest
(
c
,
"OIDC Redirect URL is required when enabled"
)
return
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
req
.
OIDCConnectRedirectURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"OIDC Redirect URL must be an absolute http(s) URL"
)
return
}
if
req
.
OIDCConnectFrontendRedirectURL
==
""
{
response
.
BadRequest
(
c
,
"OIDC Frontend Redirect URL is required when enabled"
)
return
}
if
err
:=
config
.
ValidateFrontendRedirectURL
(
req
.
OIDCConnectFrontendRedirectURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"OIDC Frontend Redirect URL is invalid"
)
return
}
if
!
scopesContainOpenID
(
req
.
OIDCConnectScopes
)
{
response
.
BadRequest
(
c
,
"OIDC scopes must contain openid"
)
return
}
switch
req
.
OIDCConnectTokenAuthMethod
{
case
""
,
"client_secret_post"
,
"client_secret_basic"
,
"none"
:
default
:
response
.
BadRequest
(
c
,
"OIDC Token Auth Method must be one of client_secret_post/client_secret_basic/none"
)
return
}
if
req
.
OIDCConnectTokenAuthMethod
==
"none"
&&
!
req
.
OIDCConnectUsePKCE
{
response
.
BadRequest
(
c
,
"OIDC PKCE must be enabled when token_auth_method=none"
)
return
}
if
req
.
OIDCConnectClockSkewSeconds
<
0
||
req
.
OIDCConnectClockSkewSeconds
>
600
{
response
.
BadRequest
(
c
,
"OIDC clock skew seconds must be between 0 and 600"
)
return
}
if
req
.
OIDCConnectValidateIDToken
{
if
req
.
OIDCConnectAllowedSigningAlgs
==
""
{
response
.
BadRequest
(
c
,
"OIDC Allowed Signing Algs is required when validate_id_token=true"
)
return
}
}
if
req
.
OIDCConnectJWKSURL
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
req
.
OIDCConnectJWKSURL
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"OIDC JWKS URL must be an absolute http(s) URL"
)
return
}
}
if
req
.
OIDCConnectTokenAuthMethod
==
""
||
req
.
OIDCConnectTokenAuthMethod
==
"client_secret_post"
||
req
.
OIDCConnectTokenAuthMethod
==
"client_secret_basic"
{
if
req
.
OIDCConnectClientSecret
==
""
{
if
previousSettings
.
OIDCConnectClientSecret
==
""
{
response
.
BadRequest
(
c
,
"OIDC Client Secret is required when enabled"
)
return
}
req
.
OIDCConnectClientSecret
=
previousSettings
.
OIDCConnectClientSecret
}
}
}
// “购买订阅”页面配置验证
purchaseEnabled
:=
previousSettings
.
PurchaseSubscriptionEnabled
if
req
.
PurchaseSubscriptionEnabled
!=
nil
{
...
...
@@ -554,6 +789,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
LinuxDoConnectClientID
:
req
.
LinuxDoConnectClientID
,
LinuxDoConnectClientSecret
:
req
.
LinuxDoConnectClientSecret
,
LinuxDoConnectRedirectURL
:
req
.
LinuxDoConnectRedirectURL
,
OIDCConnectEnabled
:
req
.
OIDCConnectEnabled
,
OIDCConnectProviderName
:
req
.
OIDCConnectProviderName
,
OIDCConnectClientID
:
req
.
OIDCConnectClientID
,
OIDCConnectClientSecret
:
req
.
OIDCConnectClientSecret
,
OIDCConnectIssuerURL
:
req
.
OIDCConnectIssuerURL
,
OIDCConnectDiscoveryURL
:
req
.
OIDCConnectDiscoveryURL
,
OIDCConnectAuthorizeURL
:
req
.
OIDCConnectAuthorizeURL
,
OIDCConnectTokenURL
:
req
.
OIDCConnectTokenURL
,
OIDCConnectUserInfoURL
:
req
.
OIDCConnectUserInfoURL
,
OIDCConnectJWKSURL
:
req
.
OIDCConnectJWKSURL
,
OIDCConnectScopes
:
req
.
OIDCConnectScopes
,
OIDCConnectRedirectURL
:
req
.
OIDCConnectRedirectURL
,
OIDCConnectFrontendRedirectURL
:
req
.
OIDCConnectFrontendRedirectURL
,
OIDCConnectTokenAuthMethod
:
req
.
OIDCConnectTokenAuthMethod
,
OIDCConnectUsePKCE
:
req
.
OIDCConnectUsePKCE
,
OIDCConnectValidateIDToken
:
req
.
OIDCConnectValidateIDToken
,
OIDCConnectAllowedSigningAlgs
:
req
.
OIDCConnectAllowedSigningAlgs
,
OIDCConnectClockSkewSeconds
:
req
.
OIDCConnectClockSkewSeconds
,
OIDCConnectRequireEmailVerified
:
req
.
OIDCConnectRequireEmailVerified
,
OIDCConnectUserInfoEmailPath
:
req
.
OIDCConnectUserInfoEmailPath
,
OIDCConnectUserInfoIDPath
:
req
.
OIDCConnectUserInfoIDPath
,
OIDCConnectUserInfoUsernamePath
:
req
.
OIDCConnectUserInfoUsernamePath
,
SiteName
:
req
.
SiteName
,
SiteLogo
:
req
.
SiteLogo
,
SiteSubtitle
:
req
.
SiteSubtitle
,
...
...
@@ -564,6 +821,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
HideCcsImportButton
:
req
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
purchaseEnabled
,
PurchaseSubscriptionURL
:
purchaseURL
,
TableDefaultPageSize
:
req
.
TableDefaultPageSize
,
TablePageSizeOptions
:
req
.
TablePageSizeOptions
,
CustomMenuItems
:
customMenuJSON
,
CustomEndpoints
:
customEndpointsJSON
,
DefaultConcurrency
:
req
.
DefaultConcurrency
,
...
...
@@ -629,6 +888,39 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
return
}
// Update payment configuration (integrated into system settings).
// Skip if no payment fields were provided (prevents accidental wipe).
if
h
.
paymentConfigService
!=
nil
&&
hasPaymentFields
(
req
)
{
paymentReq
:=
service
.
UpdatePaymentConfigRequest
{
Enabled
:
req
.
PaymentEnabled
,
MinAmount
:
req
.
PaymentMinAmount
,
MaxAmount
:
req
.
PaymentMaxAmount
,
DailyLimit
:
req
.
PaymentDailyLimit
,
OrderTimeoutMin
:
req
.
PaymentOrderTimeoutMin
,
MaxPendingOrders
:
req
.
PaymentMaxPendingOrders
,
EnabledTypes
:
req
.
PaymentEnabledTypes
,
BalanceDisabled
:
req
.
PaymentBalanceDisabled
,
LoadBalanceStrategy
:
req
.
PaymentLoadBalanceStrat
,
ProductNamePrefix
:
req
.
PaymentProductNamePrefix
,
ProductNameSuffix
:
req
.
PaymentProductNameSuffix
,
HelpImageURL
:
req
.
PaymentHelpImageURL
,
HelpText
:
req
.
PaymentHelpText
,
CancelRateLimitEnabled
:
req
.
PaymentCancelRateLimitEnabled
,
CancelRateLimitMax
:
req
.
PaymentCancelRateLimitMax
,
CancelRateLimitWindow
:
req
.
PaymentCancelRateLimitWindow
,
CancelRateLimitUnit
:
req
.
PaymentCancelRateLimitUnit
,
CancelRateLimitMode
:
req
.
PaymentCancelRateLimitMode
,
}
if
err
:=
h
.
paymentConfigService
.
UpdatePaymentConfig
(
c
.
Request
.
Context
(),
paymentReq
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Refresh in-memory provider registry so config changes take effect immediately
if
h
.
paymentService
!=
nil
{
h
.
paymentService
.
RefreshProviders
(
c
.
Request
.
Context
())
}
}
h
.
auditSettingsUpdate
(
c
,
previousSettings
,
settings
,
req
)
// 重新获取设置返回
...
...
@@ -645,6 +937,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
})
}
// Reload payment config for response
var
updatedPaymentCfg
*
service
.
PaymentConfig
if
h
.
paymentConfigService
!=
nil
{
updatedPaymentCfg
,
_
=
h
.
paymentConfigService
.
GetPaymentConfig
(
c
.
Request
.
Context
())
}
if
updatedPaymentCfg
==
nil
{
updatedPaymentCfg
=
&
service
.
PaymentConfig
{}
}
response
.
Success
(
c
,
dto
.
SystemSettings
{
RegistrationEnabled
:
updatedSettings
.
RegistrationEnabled
,
EmailVerifyEnabled
:
updatedSettings
.
EmailVerifyEnabled
,
...
...
@@ -669,6 +970,28 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
LinuxDoConnectClientID
:
updatedSettings
.
LinuxDoConnectClientID
,
LinuxDoConnectClientSecretConfigured
:
updatedSettings
.
LinuxDoConnectClientSecretConfigured
,
LinuxDoConnectRedirectURL
:
updatedSettings
.
LinuxDoConnectRedirectURL
,
OIDCConnectEnabled
:
updatedSettings
.
OIDCConnectEnabled
,
OIDCConnectProviderName
:
updatedSettings
.
OIDCConnectProviderName
,
OIDCConnectClientID
:
updatedSettings
.
OIDCConnectClientID
,
OIDCConnectClientSecretConfigured
:
updatedSettings
.
OIDCConnectClientSecretConfigured
,
OIDCConnectIssuerURL
:
updatedSettings
.
OIDCConnectIssuerURL
,
OIDCConnectDiscoveryURL
:
updatedSettings
.
OIDCConnectDiscoveryURL
,
OIDCConnectAuthorizeURL
:
updatedSettings
.
OIDCConnectAuthorizeURL
,
OIDCConnectTokenURL
:
updatedSettings
.
OIDCConnectTokenURL
,
OIDCConnectUserInfoURL
:
updatedSettings
.
OIDCConnectUserInfoURL
,
OIDCConnectJWKSURL
:
updatedSettings
.
OIDCConnectJWKSURL
,
OIDCConnectScopes
:
updatedSettings
.
OIDCConnectScopes
,
OIDCConnectRedirectURL
:
updatedSettings
.
OIDCConnectRedirectURL
,
OIDCConnectFrontendRedirectURL
:
updatedSettings
.
OIDCConnectFrontendRedirectURL
,
OIDCConnectTokenAuthMethod
:
updatedSettings
.
OIDCConnectTokenAuthMethod
,
OIDCConnectUsePKCE
:
updatedSettings
.
OIDCConnectUsePKCE
,
OIDCConnectValidateIDToken
:
updatedSettings
.
OIDCConnectValidateIDToken
,
OIDCConnectAllowedSigningAlgs
:
updatedSettings
.
OIDCConnectAllowedSigningAlgs
,
OIDCConnectClockSkewSeconds
:
updatedSettings
.
OIDCConnectClockSkewSeconds
,
OIDCConnectRequireEmailVerified
:
updatedSettings
.
OIDCConnectRequireEmailVerified
,
OIDCConnectUserInfoEmailPath
:
updatedSettings
.
OIDCConnectUserInfoEmailPath
,
OIDCConnectUserInfoIDPath
:
updatedSettings
.
OIDCConnectUserInfoIDPath
,
OIDCConnectUserInfoUsernamePath
:
updatedSettings
.
OIDCConnectUserInfoUsernamePath
,
SiteName
:
updatedSettings
.
SiteName
,
SiteLogo
:
updatedSettings
.
SiteLogo
,
SiteSubtitle
:
updatedSettings
.
SiteSubtitle
,
...
...
@@ -679,6 +1002,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
HideCcsImportButton
:
updatedSettings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
updatedSettings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
updatedSettings
.
PurchaseSubscriptionURL
,
TableDefaultPageSize
:
updatedSettings
.
TableDefaultPageSize
,
TablePageSizeOptions
:
updatedSettings
.
TablePageSizeOptions
,
CustomMenuItems
:
dto
.
ParseCustomMenuItems
(
updatedSettings
.
CustomMenuItems
),
CustomEndpoints
:
dto
.
ParseCustomEndpoints
(
updatedSettings
.
CustomEndpoints
),
DefaultConcurrency
:
updatedSettings
.
DefaultConcurrency
,
...
...
@@ -702,9 +1027,40 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableFingerprintUnification
:
updatedSettings
.
EnableFingerprintUnification
,
EnableMetadataPassthrough
:
updatedSettings
.
EnableMetadataPassthrough
,
EnableCCHSigning
:
updatedSettings
.
EnableCCHSigning
,
PaymentEnabled
:
updatedPaymentCfg
.
Enabled
,
PaymentMinAmount
:
updatedPaymentCfg
.
MinAmount
,
PaymentMaxAmount
:
updatedPaymentCfg
.
MaxAmount
,
PaymentDailyLimit
:
updatedPaymentCfg
.
DailyLimit
,
PaymentOrderTimeoutMin
:
updatedPaymentCfg
.
OrderTimeoutMin
,
PaymentMaxPendingOrders
:
updatedPaymentCfg
.
MaxPendingOrders
,
PaymentEnabledTypes
:
updatedPaymentCfg
.
EnabledTypes
,
PaymentBalanceDisabled
:
updatedPaymentCfg
.
BalanceDisabled
,
PaymentLoadBalanceStrat
:
updatedPaymentCfg
.
LoadBalanceStrategy
,
PaymentProductNamePrefix
:
updatedPaymentCfg
.
ProductNamePrefix
,
PaymentProductNameSuffix
:
updatedPaymentCfg
.
ProductNameSuffix
,
PaymentHelpImageURL
:
updatedPaymentCfg
.
HelpImageURL
,
PaymentHelpText
:
updatedPaymentCfg
.
HelpText
,
PaymentCancelRateLimitEnabled
:
updatedPaymentCfg
.
CancelRateLimitEnabled
,
PaymentCancelRateLimitMax
:
updatedPaymentCfg
.
CancelRateLimitMax
,
PaymentCancelRateLimitWindow
:
updatedPaymentCfg
.
CancelRateLimitWindow
,
PaymentCancelRateLimitUnit
:
updatedPaymentCfg
.
CancelRateLimitUnit
,
PaymentCancelRateLimitMode
:
updatedPaymentCfg
.
CancelRateLimitMode
,
})
}
// hasPaymentFields returns true if any payment-related field was explicitly provided.
func
hasPaymentFields
(
req
UpdateSettingsRequest
)
bool
{
return
req
.
PaymentEnabled
!=
nil
||
req
.
PaymentMinAmount
!=
nil
||
req
.
PaymentMaxAmount
!=
nil
||
req
.
PaymentDailyLimit
!=
nil
||
req
.
PaymentOrderTimeoutMin
!=
nil
||
req
.
PaymentMaxPendingOrders
!=
nil
||
req
.
PaymentEnabledTypes
!=
nil
||
req
.
PaymentBalanceDisabled
!=
nil
||
req
.
PaymentLoadBalanceStrat
!=
nil
||
req
.
PaymentProductNamePrefix
!=
nil
||
req
.
PaymentProductNameSuffix
!=
nil
||
req
.
PaymentHelpImageURL
!=
nil
||
req
.
PaymentHelpText
!=
nil
||
req
.
PaymentCancelRateLimitEnabled
!=
nil
||
req
.
PaymentCancelRateLimitMax
!=
nil
||
req
.
PaymentCancelRateLimitWindow
!=
nil
||
req
.
PaymentCancelRateLimitUnit
!=
nil
||
req
.
PaymentCancelRateLimitMode
!=
nil
}
func
(
h
*
SettingHandler
)
auditSettingsUpdate
(
c
*
gin
.
Context
,
before
*
service
.
SystemSettings
,
after
*
service
.
SystemSettings
,
req
UpdateSettingsRequest
)
{
if
before
==
nil
||
after
==
nil
{
return
...
...
@@ -787,6 +1143,72 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
LinuxDoConnectRedirectURL
!=
after
.
LinuxDoConnectRedirectURL
{
changed
=
append
(
changed
,
"linuxdo_connect_redirect_url"
)
}
if
before
.
OIDCConnectEnabled
!=
after
.
OIDCConnectEnabled
{
changed
=
append
(
changed
,
"oidc_connect_enabled"
)
}
if
before
.
OIDCConnectProviderName
!=
after
.
OIDCConnectProviderName
{
changed
=
append
(
changed
,
"oidc_connect_provider_name"
)
}
if
before
.
OIDCConnectClientID
!=
after
.
OIDCConnectClientID
{
changed
=
append
(
changed
,
"oidc_connect_client_id"
)
}
if
req
.
OIDCConnectClientSecret
!=
""
{
changed
=
append
(
changed
,
"oidc_connect_client_secret"
)
}
if
before
.
OIDCConnectIssuerURL
!=
after
.
OIDCConnectIssuerURL
{
changed
=
append
(
changed
,
"oidc_connect_issuer_url"
)
}
if
before
.
OIDCConnectDiscoveryURL
!=
after
.
OIDCConnectDiscoveryURL
{
changed
=
append
(
changed
,
"oidc_connect_discovery_url"
)
}
if
before
.
OIDCConnectAuthorizeURL
!=
after
.
OIDCConnectAuthorizeURL
{
changed
=
append
(
changed
,
"oidc_connect_authorize_url"
)
}
if
before
.
OIDCConnectTokenURL
!=
after
.
OIDCConnectTokenURL
{
changed
=
append
(
changed
,
"oidc_connect_token_url"
)
}
if
before
.
OIDCConnectUserInfoURL
!=
after
.
OIDCConnectUserInfoURL
{
changed
=
append
(
changed
,
"oidc_connect_userinfo_url"
)
}
if
before
.
OIDCConnectJWKSURL
!=
after
.
OIDCConnectJWKSURL
{
changed
=
append
(
changed
,
"oidc_connect_jwks_url"
)
}
if
before
.
OIDCConnectScopes
!=
after
.
OIDCConnectScopes
{
changed
=
append
(
changed
,
"oidc_connect_scopes"
)
}
if
before
.
OIDCConnectRedirectURL
!=
after
.
OIDCConnectRedirectURL
{
changed
=
append
(
changed
,
"oidc_connect_redirect_url"
)
}
if
before
.
OIDCConnectFrontendRedirectURL
!=
after
.
OIDCConnectFrontendRedirectURL
{
changed
=
append
(
changed
,
"oidc_connect_frontend_redirect_url"
)
}
if
before
.
OIDCConnectTokenAuthMethod
!=
after
.
OIDCConnectTokenAuthMethod
{
changed
=
append
(
changed
,
"oidc_connect_token_auth_method"
)
}
if
before
.
OIDCConnectUsePKCE
!=
after
.
OIDCConnectUsePKCE
{
changed
=
append
(
changed
,
"oidc_connect_use_pkce"
)
}
if
before
.
OIDCConnectValidateIDToken
!=
after
.
OIDCConnectValidateIDToken
{
changed
=
append
(
changed
,
"oidc_connect_validate_id_token"
)
}
if
before
.
OIDCConnectAllowedSigningAlgs
!=
after
.
OIDCConnectAllowedSigningAlgs
{
changed
=
append
(
changed
,
"oidc_connect_allowed_signing_algs"
)
}
if
before
.
OIDCConnectClockSkewSeconds
!=
after
.
OIDCConnectClockSkewSeconds
{
changed
=
append
(
changed
,
"oidc_connect_clock_skew_seconds"
)
}
if
before
.
OIDCConnectRequireEmailVerified
!=
after
.
OIDCConnectRequireEmailVerified
{
changed
=
append
(
changed
,
"oidc_connect_require_email_verified"
)
}
if
before
.
OIDCConnectUserInfoEmailPath
!=
after
.
OIDCConnectUserInfoEmailPath
{
changed
=
append
(
changed
,
"oidc_connect_userinfo_email_path"
)
}
if
before
.
OIDCConnectUserInfoIDPath
!=
after
.
OIDCConnectUserInfoIDPath
{
changed
=
append
(
changed
,
"oidc_connect_userinfo_id_path"
)
}
if
before
.
OIDCConnectUserInfoUsernamePath
!=
after
.
OIDCConnectUserInfoUsernamePath
{
changed
=
append
(
changed
,
"oidc_connect_userinfo_username_path"
)
}
if
before
.
SiteName
!=
after
.
SiteName
{
changed
=
append
(
changed
,
"site_name"
)
}
...
...
@@ -871,6 +1293,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if
before
.
PurchaseSubscriptionURL
!=
after
.
PurchaseSubscriptionURL
{
changed
=
append
(
changed
,
"purchase_subscription_url"
)
}
if
before
.
TableDefaultPageSize
!=
after
.
TableDefaultPageSize
{
changed
=
append
(
changed
,
"table_default_page_size"
)
}
if
!
equalIntSlice
(
before
.
TablePageSizeOptions
,
after
.
TablePageSizeOptions
)
{
changed
=
append
(
changed
,
"table_page_size_options"
)
}
if
before
.
CustomMenuItems
!=
after
.
CustomMenuItems
{
changed
=
append
(
changed
,
"custom_menu_items"
)
}
...
...
@@ -927,6 +1355,18 @@ func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
return
true
}
func
equalIntSlice
(
a
,
b
[]
int
)
bool
{
if
len
(
a
)
!=
len
(
b
)
{
return
false
}
for
i
:=
range
a
{
if
a
[
i
]
!=
b
[
i
]
{
return
false
}
}
return
true
}
// TestSMTPRequest 测试SMTP连接请求
type
TestSMTPRequest
struct
{
SMTPHost
string
`json:"smtp_host"`
...
...
backend/internal/handler/admin/usage_handler.go
View file @
a04ae28a
...
...
@@ -165,7 +165,12 @@ func (h *UsageHandler) List(c *gin.Context) {
endTime
=
&
t
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
,
SortBy
:
c
.
DefaultQuery
(
"sort_by"
,
"created_at"
),
SortOrder
:
c
.
DefaultQuery
(
"sort_order"
,
"desc"
),
}
filters
:=
usagestats
.
UsageLogFilters
{
UserID
:
userID
,
APIKeyID
:
apiKeyID
,
...
...
@@ -339,7 +344,7 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) {
}
// Limit to 30 results
users
,
_
,
err
:=
h
.
adminService
.
ListUsers
(
c
.
Request
.
Context
(),
1
,
30
,
service
.
UserListFilters
{
Search
:
keyword
})
users
,
_
,
err
:=
h
.
adminService
.
ListUsers
(
c
.
Request
.
Context
(),
1
,
30
,
service
.
UserListFilters
{
Search
:
keyword
}
,
"email"
,
"asc"
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
...
...
backend/internal/handler/admin/usage_handler_request_type_test.go
View file @
a04ae28a
...
...
@@ -15,11 +15,13 @@ import (
type
adminUsageRepoCapture
struct
{
service
.
UsageLogRepository
listParams
pagination
.
PaginationParams
listFilters
usagestats
.
UsageLogFilters
statsFilters
usagestats
.
UsageLogFilters
}
func
(
s
*
adminUsageRepoCapture
)
ListWithFilters
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
,
filters
usagestats
.
UsageLogFilters
)
([]
service
.
UsageLog
,
*
pagination
.
PaginationResult
,
error
)
{
s
.
listParams
=
params
s
.
listFilters
=
filters
return
[]
service
.
UsageLog
{},
&
pagination
.
PaginationResult
{
Total
:
0
,
...
...
backend/internal/handler/admin/usage_handler_sort_test.go
0 → 100644
View file @
a04ae28a
package
admin
import
(
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func
TestAdminUsageListSortParams
(
t
*
testing
.
T
)
{
repo
:=
&
adminUsageRepoCapture
{}
router
:=
newAdminUsageRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/usage?sort_by=model&sort_order=ASC"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
"model"
,
repo
.
listParams
.
SortBy
)
require
.
Equal
(
t
,
"ASC"
,
repo
.
listParams
.
SortOrder
)
}
func
TestAdminUsageListSortDefaults
(
t
*
testing
.
T
)
{
repo
:=
&
adminUsageRepoCapture
{}
router
:=
newAdminUsageRequestTypeTestRouter
(
repo
)
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/admin/usage"
,
nil
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
"created_at"
,
repo
.
listParams
.
SortBy
)
require
.
Equal
(
t
,
"desc"
,
repo
.
listParams
.
SortOrder
)
}
backend/internal/handler/admin/user_handler.go
View file @
a04ae28a
...
...
@@ -91,12 +91,14 @@ func (h *UserHandler) List(c *gin.Context) {
GroupName
:
strings
.
TrimSpace
(
c
.
Query
(
"group_name"
)),
Attributes
:
parseAttributeFilters
(
c
),
}
sortBy
:=
c
.
DefaultQuery
(
"sort_by"
,
"created_at"
)
sortOrder
:=
c
.
DefaultQuery
(
"sort_order"
,
"desc"
)
if
raw
,
ok
:=
c
.
GetQuery
(
"include_subscriptions"
);
ok
{
includeSubscriptions
:=
parseBoolQueryWithDefault
(
raw
,
true
)
filters
.
IncludeSubscriptions
=
&
includeSubscriptions
}
users
,
total
,
err
:=
h
.
adminService
.
ListUsers
(
c
.
Request
.
Context
(),
page
,
pageSize
,
filters
)
users
,
total
,
err
:=
h
.
adminService
.
ListUsers
(
c
.
Request
.
Context
(),
page
,
pageSize
,
filters
,
sortBy
,
sortOrder
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
...
...
@@ -290,8 +292,10 @@ func (h *UserHandler) GetUserAPIKeys(c *gin.Context) {
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
sortBy
:=
c
.
DefaultQuery
(
"sort_by"
,
"created_at"
)
sortOrder
:=
c
.
DefaultQuery
(
"sort_order"
,
"desc"
)
keys
,
total
,
err
:=
h
.
adminService
.
GetUserAPIKeys
(
c
.
Request
.
Context
(),
userID
,
page
,
pageSize
)
keys
,
total
,
err
:=
h
.
adminService
.
GetUserAPIKeys
(
c
.
Request
.
Context
(),
userID
,
page
,
pageSize
,
sortBy
,
sortOrder
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
...
...
backend/internal/handler/api_key_handler.go
View file @
a04ae28a
...
...
@@ -72,7 +72,12 @@ func (h *APIKeyHandler) List(c *gin.Context) {
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
}
params
:=
pagination
.
PaginationParams
{
Page
:
page
,
PageSize
:
pageSize
,
SortBy
:
c
.
DefaultQuery
(
"sort_by"
,
"created_at"
),
SortOrder
:
c
.
DefaultQuery
(
"sort_order"
,
"desc"
),
}
// Parse filter parameters
var
filters
service
.
APIKeyListFilters
...
...
backend/internal/handler/auth_oidc_oauth.go
0 → 100644
View file @
a04ae28a
package
handler
import
(
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math/big"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/imroc/req/v3"
"github.com/tidwall/gjson"
)
const
(
oidcOAuthCookiePath
=
"/api/v1/auth/oauth/oidc"
oidcOAuthStateCookieName
=
"oidc_oauth_state"
oidcOAuthVerifierCookie
=
"oidc_oauth_verifier"
oidcOAuthRedirectCookie
=
"oidc_oauth_redirect"
oidcOAuthNonceCookie
=
"oidc_oauth_nonce"
oidcOAuthCookieMaxAgeSec
=
10
*
60
// 10 minutes
oidcOAuthDefaultRedirectTo
=
"/dashboard"
oidcOAuthDefaultFrontendCB
=
"/auth/oidc/callback"
)
type
oidcTokenResponse
struct
{
AccessToken
string
`json:"access_token"`
TokenType
string
`json:"token_type"`
ExpiresIn
int64
`json:"expires_in"`
RefreshToken
string
`json:"refresh_token,omitempty"`
Scope
string
`json:"scope,omitempty"`
IDToken
string
`json:"id_token,omitempty"`
}
type
oidcTokenExchangeError
struct
{
StatusCode
int
ProviderError
string
ProviderDescription
string
Body
string
}
func
(
e
*
oidcTokenExchangeError
)
Error
()
string
{
if
e
==
nil
{
return
""
}
parts
:=
[]
string
{
fmt
.
Sprintf
(
"token exchange status=%d"
,
e
.
StatusCode
)}
if
strings
.
TrimSpace
(
e
.
ProviderError
)
!=
""
{
parts
=
append
(
parts
,
"error="
+
strings
.
TrimSpace
(
e
.
ProviderError
))
}
if
strings
.
TrimSpace
(
e
.
ProviderDescription
)
!=
""
{
parts
=
append
(
parts
,
"error_description="
+
strings
.
TrimSpace
(
e
.
ProviderDescription
))
}
return
strings
.
Join
(
parts
,
" "
)
}
type
oidcIDTokenClaims
struct
{
Email
string
`json:"email,omitempty"`
EmailVerified
*
bool
`json:"email_verified,omitempty"`
PreferredUsername
string
`json:"preferred_username,omitempty"`
Name
string
`json:"name,omitempty"`
Nonce
string
`json:"nonce,omitempty"`
Azp
string
`json:"azp,omitempty"`
jwt
.
RegisteredClaims
}
type
oidcUserInfoClaims
struct
{
Email
string
Username
string
Subject
string
EmailVerified
*
bool
}
type
oidcJWKSet
struct
{
Keys
[]
oidcJWK
`json:"keys"`
}
type
oidcJWK
struct
{
Kty
string
`json:"kty"`
Kid
string
`json:"kid"`
Use
string
`json:"use"`
Alg
string
`json:"alg"`
N
string
`json:"n"`
E
string
`json:"e"`
Crv
string
`json:"crv"`
X
string
`json:"x"`
Y
string
`json:"y"`
}
// OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。
// GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard
func
(
h
*
AuthHandler
)
OIDCOAuthStart
(
c
*
gin
.
Context
)
{
cfg
,
err
:=
h
.
getOIDCOAuthConfig
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
state
,
err
:=
oauth
.
GenerateState
()
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"OAUTH_STATE_GEN_FAILED"
,
"failed to generate oauth state"
)
.
WithCause
(
err
))
return
}
redirectTo
:=
sanitizeFrontendRedirectPath
(
c
.
Query
(
"redirect"
))
if
redirectTo
==
""
{
redirectTo
=
oidcOAuthDefaultRedirectTo
}
secureCookie
:=
isRequestHTTPS
(
c
)
oidcSetCookie
(
c
,
oidcOAuthStateCookieName
,
encodeCookieValue
(
state
),
oidcOAuthCookieMaxAgeSec
,
secureCookie
)
oidcSetCookie
(
c
,
oidcOAuthRedirectCookie
,
encodeCookieValue
(
redirectTo
),
oidcOAuthCookieMaxAgeSec
,
secureCookie
)
codeChallenge
:=
""
if
cfg
.
UsePKCE
{
verifier
,
genErr
:=
oauth
.
GenerateCodeVerifier
()
if
genErr
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"OAUTH_PKCE_GEN_FAILED"
,
"failed to generate pkce verifier"
)
.
WithCause
(
genErr
))
return
}
codeChallenge
=
oauth
.
GenerateCodeChallenge
(
verifier
)
oidcSetCookie
(
c
,
oidcOAuthVerifierCookie
,
encodeCookieValue
(
verifier
),
oidcOAuthCookieMaxAgeSec
,
secureCookie
)
}
nonce
:=
""
if
cfg
.
ValidateIDToken
{
nonce
,
err
=
oauth
.
GenerateState
()
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"OAUTH_NONCE_GEN_FAILED"
,
"failed to generate oauth nonce"
)
.
WithCause
(
err
))
return
}
oidcSetCookie
(
c
,
oidcOAuthNonceCookie
,
encodeCookieValue
(
nonce
),
oidcOAuthCookieMaxAgeSec
,
secureCookie
)
}
redirectURI
:=
strings
.
TrimSpace
(
cfg
.
RedirectURL
)
if
redirectURI
==
""
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth redirect url not configured"
))
return
}
authURL
,
err
:=
buildOIDCAuthorizeURL
(
cfg
,
state
,
nonce
,
codeChallenge
,
redirectURI
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
infraerrors
.
InternalServer
(
"OAUTH_BUILD_URL_FAILED"
,
"failed to build oauth authorization url"
)
.
WithCause
(
err
))
return
}
c
.
Redirect
(
http
.
StatusFound
,
authURL
)
}
// OIDCOAuthCallback 处理 OIDC 回调:校验 id_token、创建/登录用户并重定向到前端。
// GET /api/v1/auth/oauth/oidc/callback?code=...&state=...
func
(
h
*
AuthHandler
)
OIDCOAuthCallback
(
c
*
gin
.
Context
)
{
cfg
,
cfgErr
:=
h
.
getOIDCOAuthConfig
(
c
.
Request
.
Context
())
if
cfgErr
!=
nil
{
response
.
ErrorFrom
(
c
,
cfgErr
)
return
}
frontendCallback
:=
strings
.
TrimSpace
(
cfg
.
FrontendRedirectURL
)
if
frontendCallback
==
""
{
frontendCallback
=
oidcOAuthDefaultFrontendCB
}
if
providerErr
:=
strings
.
TrimSpace
(
c
.
Query
(
"error"
));
providerErr
!=
""
{
redirectOAuthError
(
c
,
frontendCallback
,
"provider_error"
,
providerErr
,
c
.
Query
(
"error_description"
))
return
}
code
:=
strings
.
TrimSpace
(
c
.
Query
(
"code"
))
state
:=
strings
.
TrimSpace
(
c
.
Query
(
"state"
))
if
code
==
""
||
state
==
""
{
redirectOAuthError
(
c
,
frontendCallback
,
"missing_params"
,
"missing code/state"
,
""
)
return
}
secureCookie
:=
isRequestHTTPS
(
c
)
defer
func
()
{
oidcClearCookie
(
c
,
oidcOAuthStateCookieName
,
secureCookie
)
oidcClearCookie
(
c
,
oidcOAuthVerifierCookie
,
secureCookie
)
oidcClearCookie
(
c
,
oidcOAuthRedirectCookie
,
secureCookie
)
oidcClearCookie
(
c
,
oidcOAuthNonceCookie
,
secureCookie
)
}()
expectedState
,
err
:=
readCookieDecoded
(
c
,
oidcOAuthStateCookieName
)
if
err
!=
nil
||
expectedState
==
""
||
state
!=
expectedState
{
redirectOAuthError
(
c
,
frontendCallback
,
"invalid_state"
,
"invalid oauth state"
,
""
)
return
}
redirectTo
,
_
:=
readCookieDecoded
(
c
,
oidcOAuthRedirectCookie
)
redirectTo
=
sanitizeFrontendRedirectPath
(
redirectTo
)
if
redirectTo
==
""
{
redirectTo
=
oidcOAuthDefaultRedirectTo
}
codeVerifier
:=
""
if
cfg
.
UsePKCE
{
codeVerifier
,
_
=
readCookieDecoded
(
c
,
oidcOAuthVerifierCookie
)
if
codeVerifier
==
""
{
redirectOAuthError
(
c
,
frontendCallback
,
"missing_verifier"
,
"missing pkce verifier"
,
""
)
return
}
}
expectedNonce
:=
""
if
cfg
.
ValidateIDToken
{
expectedNonce
,
_
=
readCookieDecoded
(
c
,
oidcOAuthNonceCookie
)
if
expectedNonce
==
""
{
redirectOAuthError
(
c
,
frontendCallback
,
"missing_nonce"
,
"missing oauth nonce"
,
""
)
return
}
}
redirectURI
:=
strings
.
TrimSpace
(
cfg
.
RedirectURL
)
if
redirectURI
==
""
{
redirectOAuthError
(
c
,
frontendCallback
,
"config_error"
,
"oauth redirect url not configured"
,
""
)
return
}
tokenResp
,
err
:=
oidcExchangeCode
(
c
.
Request
.
Context
(),
cfg
,
code
,
redirectURI
,
codeVerifier
)
if
err
!=
nil
{
description
:=
""
var
exchangeErr
*
oidcTokenExchangeError
if
errors
.
As
(
err
,
&
exchangeErr
)
&&
exchangeErr
!=
nil
{
log
.
Printf
(
"[OIDC OAuth] token exchange failed: status=%d provider_error=%q provider_description=%q body=%s"
,
exchangeErr
.
StatusCode
,
exchangeErr
.
ProviderError
,
exchangeErr
.
ProviderDescription
,
truncateLogValue
(
exchangeErr
.
Body
,
2048
),
)
description
=
exchangeErr
.
Error
()
}
else
{
log
.
Printf
(
"[OIDC OAuth] token exchange failed: %v"
,
err
)
description
=
err
.
Error
()
}
redirectOAuthError
(
c
,
frontendCallback
,
"token_exchange_failed"
,
"failed to exchange oauth code"
,
singleLine
(
description
))
return
}
if
cfg
.
ValidateIDToken
&&
strings
.
TrimSpace
(
tokenResp
.
IDToken
)
==
""
{
redirectOAuthError
(
c
,
frontendCallback
,
"missing_id_token"
,
"missing id_token"
,
""
)
return
}
idClaims
,
err
:=
oidcParseAndValidateIDToken
(
c
.
Request
.
Context
(),
cfg
,
tokenResp
.
IDToken
,
expectedNonce
)
if
err
!=
nil
{
log
.
Printf
(
"[OIDC OAuth] id_token validation failed: %v"
,
err
)
redirectOAuthError
(
c
,
frontendCallback
,
"invalid_id_token"
,
"failed to validate id_token"
,
""
)
return
}
userInfoClaims
,
err
:=
oidcFetchUserInfo
(
c
.
Request
.
Context
(),
cfg
,
tokenResp
)
if
err
!=
nil
{
log
.
Printf
(
"[OIDC OAuth] userinfo fetch failed: %v"
,
err
)
redirectOAuthError
(
c
,
frontendCallback
,
"userinfo_failed"
,
"failed to fetch user info"
,
""
)
return
}
subject
:=
strings
.
TrimSpace
(
idClaims
.
Subject
)
if
subject
==
""
{
subject
=
strings
.
TrimSpace
(
userInfoClaims
.
Subject
)
}
if
subject
==
""
{
redirectOAuthError
(
c
,
frontendCallback
,
"missing_subject"
,
"missing subject claim"
,
""
)
return
}
issuer
:=
strings
.
TrimSpace
(
idClaims
.
Issuer
)
if
issuer
==
""
{
issuer
=
strings
.
TrimSpace
(
cfg
.
IssuerURL
)
}
if
issuer
==
""
{
redirectOAuthError
(
c
,
frontendCallback
,
"missing_issuer"
,
"missing issuer claim"
,
""
)
return
}
emailVerified
:=
userInfoClaims
.
EmailVerified
if
emailVerified
==
nil
{
emailVerified
=
idClaims
.
EmailVerified
}
if
cfg
.
RequireEmailVerified
{
if
emailVerified
==
nil
||
!*
emailVerified
{
redirectOAuthError
(
c
,
frontendCallback
,
"email_not_verified"
,
"email is not verified"
,
""
)
return
}
}
identityKey
:=
oidcIdentityKey
(
issuer
,
subject
)
email
:=
oidcSelectLoginEmail
(
userInfoClaims
.
Email
,
idClaims
.
Email
,
identityKey
)
username
:=
firstNonEmpty
(
userInfoClaims
.
Username
,
idClaims
.
PreferredUsername
,
idClaims
.
Name
,
oidcFallbackUsername
(
subject
),
)
// 传入空邀请码;如果需要邀请码,服务层返回 ErrOAuthInvitationRequired
tokenPair
,
_
,
err
:=
h
.
authService
.
LoginOrRegisterOAuthWithTokenPair
(
c
.
Request
.
Context
(),
email
,
username
,
""
)
if
err
!=
nil
{
if
errors
.
Is
(
err
,
service
.
ErrOAuthInvitationRequired
)
{
pendingToken
,
tokenErr
:=
h
.
authService
.
CreatePendingOAuthToken
(
email
,
username
)
if
tokenErr
!=
nil
{
redirectOAuthError
(
c
,
frontendCallback
,
"login_failed"
,
"service_error"
,
""
)
return
}
fragment
:=
url
.
Values
{}
fragment
.
Set
(
"error"
,
"invitation_required"
)
fragment
.
Set
(
"pending_oauth_token"
,
pendingToken
)
fragment
.
Set
(
"redirect"
,
redirectTo
)
redirectWithFragment
(
c
,
frontendCallback
,
fragment
)
return
}
redirectOAuthError
(
c
,
frontendCallback
,
"login_failed"
,
infraerrors
.
Reason
(
err
),
infraerrors
.
Message
(
err
))
return
}
fragment
:=
url
.
Values
{}
fragment
.
Set
(
"access_token"
,
tokenPair
.
AccessToken
)
fragment
.
Set
(
"refresh_token"
,
tokenPair
.
RefreshToken
)
fragment
.
Set
(
"expires_in"
,
fmt
.
Sprintf
(
"%d"
,
tokenPair
.
ExpiresIn
))
fragment
.
Set
(
"token_type"
,
"Bearer"
)
fragment
.
Set
(
"redirect"
,
redirectTo
)
redirectWithFragment
(
c
,
frontendCallback
,
fragment
)
}
type
completeOIDCOAuthRequest
struct
{
PendingOAuthToken
string
`json:"pending_oauth_token" binding:"required"`
InvitationCode
string
`json:"invitation_code" binding:"required"`
}
// CompleteOIDCOAuthRegistration completes a pending OAuth registration by validating
// the invitation code and creating the user account.
// POST /api/v1/auth/oauth/oidc/complete-registration
func
(
h
*
AuthHandler
)
CompleteOIDCOAuthRegistration
(
c
*
gin
.
Context
)
{
var
req
completeOIDCOAuthRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
c
.
JSON
(
http
.
StatusBadRequest
,
gin
.
H
{
"error"
:
"INVALID_REQUEST"
,
"message"
:
err
.
Error
()})
return
}
email
,
username
,
err
:=
h
.
authService
.
VerifyPendingOAuthToken
(
req
.
PendingOAuthToken
)
if
err
!=
nil
{
c
.
JSON
(
http
.
StatusUnauthorized
,
gin
.
H
{
"error"
:
"INVALID_TOKEN"
,
"message"
:
"invalid or expired registration token"
})
return
}
tokenPair
,
_
,
err
:=
h
.
authService
.
LoginOrRegisterOAuthWithTokenPair
(
c
.
Request
.
Context
(),
email
,
username
,
req
.
InvitationCode
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
c
.
JSON
(
http
.
StatusOK
,
gin
.
H
{
"access_token"
:
tokenPair
.
AccessToken
,
"refresh_token"
:
tokenPair
.
RefreshToken
,
"expires_in"
:
tokenPair
.
ExpiresIn
,
"token_type"
:
"Bearer"
,
})
}
func
(
h
*
AuthHandler
)
getOIDCOAuthConfig
(
ctx
context
.
Context
)
(
config
.
OIDCConnectConfig
,
error
)
{
if
h
!=
nil
&&
h
.
settingSvc
!=
nil
{
return
h
.
settingSvc
.
GetOIDCConnectOAuthConfig
(
ctx
)
}
if
h
==
nil
||
h
.
cfg
==
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
ServiceUnavailable
(
"CONFIG_NOT_READY"
,
"config not loaded"
)
}
if
!
h
.
cfg
.
OIDC
.
Enabled
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
NotFound
(
"OAUTH_DISABLED"
,
"oauth login is disabled"
)
}
return
h
.
cfg
.
OIDC
,
nil
}
func
oidcExchangeCode
(
ctx
context
.
Context
,
cfg
config
.
OIDCConnectConfig
,
code
string
,
redirectURI
string
,
codeVerifier
string
,
)
(
*
oidcTokenResponse
,
error
)
{
client
:=
req
.
C
()
.
SetTimeout
(
30
*
time
.
Second
)
form
:=
url
.
Values
{}
form
.
Set
(
"grant_type"
,
"authorization_code"
)
form
.
Set
(
"client_id"
,
cfg
.
ClientID
)
form
.
Set
(
"code"
,
code
)
form
.
Set
(
"redirect_uri"
,
redirectURI
)
if
cfg
.
UsePKCE
{
form
.
Set
(
"code_verifier"
,
codeVerifier
)
}
r
:=
client
.
R
()
.
SetContext
(
ctx
)
.
SetHeader
(
"Accept"
,
"application/json"
)
switch
strings
.
ToLower
(
strings
.
TrimSpace
(
cfg
.
TokenAuthMethod
))
{
case
""
,
"client_secret_post"
:
form
.
Set
(
"client_secret"
,
cfg
.
ClientSecret
)
case
"client_secret_basic"
:
r
.
SetBasicAuth
(
cfg
.
ClientID
,
cfg
.
ClientSecret
)
case
"none"
:
default
:
return
nil
,
fmt
.
Errorf
(
"unsupported token_auth_method: %s"
,
cfg
.
TokenAuthMethod
)
}
resp
,
err
:=
r
.
SetFormDataFromValues
(
form
)
.
Post
(
cfg
.
TokenURL
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"request token: %w"
,
err
)
}
body
:=
strings
.
TrimSpace
(
resp
.
String
())
if
!
resp
.
IsSuccessState
()
{
providerErr
,
providerDesc
:=
parseOAuthProviderError
(
body
)
return
nil
,
&
oidcTokenExchangeError
{
StatusCode
:
resp
.
StatusCode
,
ProviderError
:
providerErr
,
ProviderDescription
:
providerDesc
,
Body
:
body
,
}
}
tokenResp
,
ok
:=
oidcParseTokenResponse
(
body
)
if
!
ok
{
return
nil
,
&
oidcTokenExchangeError
{
StatusCode
:
resp
.
StatusCode
,
Body
:
body
}
}
if
strings
.
TrimSpace
(
tokenResp
.
TokenType
)
==
""
{
tokenResp
.
TokenType
=
"Bearer"
}
if
strings
.
TrimSpace
(
tokenResp
.
AccessToken
)
==
""
&&
strings
.
TrimSpace
(
tokenResp
.
IDToken
)
==
""
{
return
nil
,
&
oidcTokenExchangeError
{
StatusCode
:
resp
.
StatusCode
,
Body
:
body
}
}
return
tokenResp
,
nil
}
func
oidcParseTokenResponse
(
body
string
)
(
*
oidcTokenResponse
,
bool
)
{
body
=
strings
.
TrimSpace
(
body
)
if
body
==
""
{
return
nil
,
false
}
accessToken
:=
strings
.
TrimSpace
(
getGJSON
(
body
,
"access_token"
))
idToken
:=
strings
.
TrimSpace
(
getGJSON
(
body
,
"id_token"
))
if
accessToken
!=
""
||
idToken
!=
""
{
tokenType
:=
strings
.
TrimSpace
(
getGJSON
(
body
,
"token_type"
))
refreshToken
:=
strings
.
TrimSpace
(
getGJSON
(
body
,
"refresh_token"
))
scope
:=
strings
.
TrimSpace
(
getGJSON
(
body
,
"scope"
))
expiresIn
:=
gjson
.
Get
(
body
,
"expires_in"
)
.
Int
()
return
&
oidcTokenResponse
{
AccessToken
:
accessToken
,
TokenType
:
tokenType
,
ExpiresIn
:
expiresIn
,
RefreshToken
:
refreshToken
,
Scope
:
scope
,
IDToken
:
idToken
,
},
true
}
values
,
err
:=
url
.
ParseQuery
(
body
)
if
err
!=
nil
{
return
nil
,
false
}
accessToken
=
strings
.
TrimSpace
(
values
.
Get
(
"access_token"
))
idToken
=
strings
.
TrimSpace
(
values
.
Get
(
"id_token"
))
if
accessToken
==
""
&&
idToken
==
""
{
return
nil
,
false
}
expiresIn
:=
int64
(
0
)
if
raw
:=
strings
.
TrimSpace
(
values
.
Get
(
"expires_in"
));
raw
!=
""
{
if
v
,
parseErr
:=
strconv
.
ParseInt
(
raw
,
10
,
64
);
parseErr
==
nil
{
expiresIn
=
v
}
}
return
&
oidcTokenResponse
{
AccessToken
:
accessToken
,
TokenType
:
strings
.
TrimSpace
(
values
.
Get
(
"token_type"
)),
ExpiresIn
:
expiresIn
,
RefreshToken
:
strings
.
TrimSpace
(
values
.
Get
(
"refresh_token"
)),
Scope
:
strings
.
TrimSpace
(
values
.
Get
(
"scope"
)),
IDToken
:
idToken
,
},
true
}
func
oidcFetchUserInfo
(
ctx
context
.
Context
,
cfg
config
.
OIDCConnectConfig
,
token
*
oidcTokenResponse
,
)
(
*
oidcUserInfoClaims
,
error
)
{
if
strings
.
TrimSpace
(
cfg
.
UserInfoURL
)
==
""
{
return
&
oidcUserInfoClaims
{},
nil
}
if
token
==
nil
||
strings
.
TrimSpace
(
token
.
AccessToken
)
==
""
{
return
nil
,
errors
.
New
(
"missing access_token for userinfo request"
)
}
client
:=
req
.
C
()
.
SetTimeout
(
30
*
time
.
Second
)
authorization
,
err
:=
buildBearerAuthorization
(
token
.
TokenType
,
token
.
AccessToken
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"invalid token for userinfo request: %w"
,
err
)
}
resp
,
err
:=
client
.
R
()
.
SetContext
(
ctx
)
.
SetHeader
(
"Accept"
,
"application/json"
)
.
SetHeader
(
"Authorization"
,
authorization
)
.
Get
(
cfg
.
UserInfoURL
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"request userinfo: %w"
,
err
)
}
if
!
resp
.
IsSuccessState
()
{
return
nil
,
fmt
.
Errorf
(
"userinfo status=%d"
,
resp
.
StatusCode
)
}
return
oidcParseUserInfo
(
resp
.
String
(),
cfg
),
nil
}
func
oidcParseUserInfo
(
body
string
,
cfg
config
.
OIDCConnectConfig
)
*
oidcUserInfoClaims
{
claims
:=
&
oidcUserInfoClaims
{}
claims
.
Email
=
firstNonEmpty
(
getGJSON
(
body
,
cfg
.
UserInfoEmailPath
),
getGJSON
(
body
,
"email"
),
getGJSON
(
body
,
"user.email"
),
getGJSON
(
body
,
"data.email"
),
getGJSON
(
body
,
"attributes.email"
),
)
claims
.
Username
=
firstNonEmpty
(
getGJSON
(
body
,
cfg
.
UserInfoUsernamePath
),
getGJSON
(
body
,
"preferred_username"
),
getGJSON
(
body
,
"username"
),
getGJSON
(
body
,
"name"
),
getGJSON
(
body
,
"user.username"
),
getGJSON
(
body
,
"user.name"
),
)
claims
.
Subject
=
firstNonEmpty
(
getGJSON
(
body
,
cfg
.
UserInfoIDPath
),
getGJSON
(
body
,
"sub"
),
getGJSON
(
body
,
"id"
),
getGJSON
(
body
,
"user_id"
),
getGJSON
(
body
,
"uid"
),
getGJSON
(
body
,
"user.id"
),
)
if
verified
,
ok
:=
getGJSONBool
(
body
,
"email_verified"
);
ok
{
claims
.
EmailVerified
=
&
verified
}
claims
.
Email
=
strings
.
TrimSpace
(
claims
.
Email
)
claims
.
Username
=
strings
.
TrimSpace
(
claims
.
Username
)
claims
.
Subject
=
strings
.
TrimSpace
(
claims
.
Subject
)
return
claims
}
func
getGJSONBool
(
body
string
,
path
string
)
(
bool
,
bool
)
{
path
=
strings
.
TrimSpace
(
path
)
if
path
==
""
{
return
false
,
false
}
res
:=
gjson
.
Get
(
body
,
path
)
if
!
res
.
Exists
()
{
return
false
,
false
}
return
res
.
Bool
(),
true
}
func
buildOIDCAuthorizeURL
(
cfg
config
.
OIDCConnectConfig
,
state
,
nonce
,
codeChallenge
,
redirectURI
string
)
(
string
,
error
)
{
u
,
err
:=
url
.
Parse
(
cfg
.
AuthorizeURL
)
if
err
!=
nil
{
return
""
,
fmt
.
Errorf
(
"parse authorize_url: %w"
,
err
)
}
q
:=
u
.
Query
()
q
.
Set
(
"response_type"
,
"code"
)
q
.
Set
(
"client_id"
,
cfg
.
ClientID
)
q
.
Set
(
"redirect_uri"
,
redirectURI
)
if
strings
.
TrimSpace
(
cfg
.
Scopes
)
!=
""
{
q
.
Set
(
"scope"
,
cfg
.
Scopes
)
}
q
.
Set
(
"state"
,
state
)
if
strings
.
TrimSpace
(
nonce
)
!=
""
{
q
.
Set
(
"nonce"
,
nonce
)
}
if
cfg
.
UsePKCE
{
q
.
Set
(
"code_challenge"
,
codeChallenge
)
q
.
Set
(
"code_challenge_method"
,
"S256"
)
}
u
.
RawQuery
=
q
.
Encode
()
return
u
.
String
(),
nil
}
func
oidcParseAndValidateIDToken
(
ctx
context
.
Context
,
cfg
config
.
OIDCConnectConfig
,
idToken
string
,
expectedNonce
string
)
(
*
oidcIDTokenClaims
,
error
)
{
idToken
=
strings
.
TrimSpace
(
idToken
)
if
idToken
==
""
{
return
nil
,
errors
.
New
(
"missing id_token"
)
}
allowed
:=
oidcAllowedSigningAlgs
(
cfg
.
AllowedSigningAlgs
)
if
len
(
allowed
)
==
0
{
return
nil
,
errors
.
New
(
"empty allowed signing algorithms"
)
}
jwks
,
err
:=
oidcFetchJWKSet
(
ctx
,
cfg
.
JWKSURL
)
if
err
!=
nil
{
return
nil
,
err
}
leeway
:=
time
.
Duration
(
cfg
.
ClockSkewSeconds
)
*
time
.
Second
claims
:=
&
oidcIDTokenClaims
{}
parsed
,
err
:=
jwt
.
ParseWithClaims
(
idToken
,
claims
,
func
(
token
*
jwt
.
Token
)
(
any
,
error
)
{
alg
:=
strings
.
TrimSpace
(
token
.
Method
.
Alg
())
if
!
containsString
(
allowed
,
alg
)
{
return
nil
,
fmt
.
Errorf
(
"unexpected signing algorithm: %s"
,
alg
)
}
kid
,
_
:=
token
.
Header
[
"kid"
]
.
(
string
)
return
oidcFindPublicKey
(
jwks
,
strings
.
TrimSpace
(
kid
),
alg
)
},
jwt
.
WithValidMethods
(
allowed
),
jwt
.
WithAudience
(
cfg
.
ClientID
),
jwt
.
WithIssuer
(
cfg
.
IssuerURL
),
jwt
.
WithLeeway
(
leeway
),
)
if
err
!=
nil
{
return
nil
,
err
}
if
!
parsed
.
Valid
{
return
nil
,
errors
.
New
(
"id_token invalid"
)
}
if
strings
.
TrimSpace
(
claims
.
Subject
)
==
""
{
return
nil
,
errors
.
New
(
"id_token missing sub"
)
}
if
expectedNonce
!=
""
&&
strings
.
TrimSpace
(
claims
.
Nonce
)
!=
strings
.
TrimSpace
(
expectedNonce
)
{
return
nil
,
errors
.
New
(
"id_token nonce mismatch"
)
}
if
len
(
claims
.
Audience
)
>
1
{
if
strings
.
TrimSpace
(
claims
.
Azp
)
==
""
||
strings
.
TrimSpace
(
claims
.
Azp
)
!=
strings
.
TrimSpace
(
cfg
.
ClientID
)
{
return
nil
,
errors
.
New
(
"id_token azp mismatch"
)
}
}
return
claims
,
nil
}
func
oidcAllowedSigningAlgs
(
raw
string
)
[]
string
{
if
strings
.
TrimSpace
(
raw
)
==
""
{
return
[]
string
{
"RS256"
,
"ES256"
,
"PS256"
}
}
seen
:=
make
(
map
[
string
]
struct
{})
out
:=
make
([]
string
,
0
,
4
)
for
_
,
part
:=
range
strings
.
Split
(
raw
,
","
)
{
alg
:=
strings
.
ToUpper
(
strings
.
TrimSpace
(
part
))
if
alg
==
""
{
continue
}
if
_
,
ok
:=
seen
[
alg
];
ok
{
continue
}
seen
[
alg
]
=
struct
{}{}
out
=
append
(
out
,
alg
)
}
return
out
}
func
oidcFetchJWKSet
(
ctx
context
.
Context
,
jwksURL
string
)
(
*
oidcJWKSet
,
error
)
{
jwksURL
=
strings
.
TrimSpace
(
jwksURL
)
if
jwksURL
==
""
{
return
nil
,
errors
.
New
(
"missing jwks_url"
)
}
resp
,
err
:=
req
.
C
()
.
SetTimeout
(
30
*
time
.
Second
)
.
R
()
.
SetContext
(
ctx
)
.
SetHeader
(
"Accept"
,
"application/json"
)
.
Get
(
jwksURL
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"request jwks: %w"
,
err
)
}
if
!
resp
.
IsSuccessState
()
{
return
nil
,
fmt
.
Errorf
(
"jwks status=%d"
,
resp
.
StatusCode
)
}
set
:=
&
oidcJWKSet
{}
if
err
:=
json
.
Unmarshal
(
resp
.
Bytes
(),
set
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"parse jwks: %w"
,
err
)
}
if
len
(
set
.
Keys
)
==
0
{
return
nil
,
errors
.
New
(
"jwks empty keys"
)
}
return
set
,
nil
}
func
oidcFindPublicKey
(
set
*
oidcJWKSet
,
kid
,
alg
string
)
(
any
,
error
)
{
if
set
==
nil
{
return
nil
,
errors
.
New
(
"jwks not loaded"
)
}
alg
=
strings
.
ToUpper
(
strings
.
TrimSpace
(
alg
))
kid
=
strings
.
TrimSpace
(
kid
)
var
lastErr
error
for
i
:=
range
set
.
Keys
{
k
:=
set
.
Keys
[
i
]
if
strings
.
TrimSpace
(
k
.
Use
)
!=
""
&&
!
strings
.
EqualFold
(
strings
.
TrimSpace
(
k
.
Use
),
"sig"
)
{
continue
}
if
kid
!=
""
&&
strings
.
TrimSpace
(
k
.
Kid
)
!=
kid
{
continue
}
if
strings
.
TrimSpace
(
k
.
Alg
)
!=
""
&&
!
strings
.
EqualFold
(
strings
.
TrimSpace
(
k
.
Alg
),
alg
)
{
continue
}
pk
,
err
:=
k
.
publicKey
()
if
err
!=
nil
{
lastErr
=
err
continue
}
if
pk
!=
nil
{
return
pk
,
nil
}
}
if
lastErr
!=
nil
{
return
nil
,
lastErr
}
if
kid
!=
""
{
return
nil
,
fmt
.
Errorf
(
"jwk not found for kid=%s"
,
kid
)
}
return
nil
,
errors
.
New
(
"jwk not found"
)
}
func
(
k
oidcJWK
)
publicKey
()
(
any
,
error
)
{
switch
strings
.
ToUpper
(
strings
.
TrimSpace
(
k
.
Kty
))
{
case
"RSA"
:
n
,
err
:=
decodeBase64URLBigInt
(
k
.
N
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"decode rsa n: %w"
,
err
)
}
eBytes
,
err
:=
base64
.
RawURLEncoding
.
DecodeString
(
strings
.
TrimSpace
(
k
.
E
))
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"decode rsa e: %w"
,
err
)
}
if
len
(
eBytes
)
==
0
{
return
nil
,
errors
.
New
(
"empty rsa e"
)
}
e
:=
0
for
_
,
b
:=
range
eBytes
{
e
=
(
e
<<
8
)
|
int
(
b
)
}
if
e
<=
0
{
return
nil
,
errors
.
New
(
"invalid rsa exponent"
)
}
if
n
.
Sign
()
<=
0
{
return
nil
,
errors
.
New
(
"invalid rsa modulus"
)
}
return
&
rsa
.
PublicKey
{
N
:
n
,
E
:
e
},
nil
case
"EC"
:
var
curve
elliptic
.
Curve
switch
strings
.
TrimSpace
(
k
.
Crv
)
{
case
"P-256"
:
curve
=
elliptic
.
P256
()
case
"P-384"
:
curve
=
elliptic
.
P384
()
case
"P-521"
:
curve
=
elliptic
.
P521
()
default
:
return
nil
,
fmt
.
Errorf
(
"unsupported ec curve: %s"
,
k
.
Crv
)
}
x
,
err
:=
decodeBase64URLBigInt
(
k
.
X
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"decode ec x: %w"
,
err
)
}
y
,
err
:=
decodeBase64URLBigInt
(
k
.
Y
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"decode ec y: %w"
,
err
)
}
if
!
curve
.
IsOnCurve
(
x
,
y
)
{
return
nil
,
errors
.
New
(
"ec point is not on curve"
)
}
return
&
ecdsa
.
PublicKey
{
Curve
:
curve
,
X
:
x
,
Y
:
y
},
nil
default
:
return
nil
,
fmt
.
Errorf
(
"unsupported jwk kty: %s"
,
k
.
Kty
)
}
}
func
decodeBase64URLBigInt
(
raw
string
)
(
*
big
.
Int
,
error
)
{
buf
,
err
:=
base64
.
RawURLEncoding
.
DecodeString
(
strings
.
TrimSpace
(
raw
))
if
err
!=
nil
{
return
nil
,
err
}
if
len
(
buf
)
==
0
{
return
nil
,
errors
.
New
(
"empty value"
)
}
return
new
(
big
.
Int
)
.
SetBytes
(
buf
),
nil
}
func
containsString
(
values
[]
string
,
target
string
)
bool
{
target
=
strings
.
TrimSpace
(
target
)
for
_
,
v
:=
range
values
{
if
strings
.
EqualFold
(
strings
.
TrimSpace
(
v
),
target
)
{
return
true
}
}
return
false
}
func
oidcIdentityKey
(
issuer
,
subject
string
)
string
{
issuer
=
strings
.
TrimSpace
(
strings
.
ToLower
(
issuer
))
subject
=
strings
.
TrimSpace
(
subject
)
return
issuer
+
"
\x1f
"
+
subject
}
func
oidcSyntheticEmailFromIdentityKey
(
identityKey
string
)
string
{
identityKey
=
strings
.
TrimSpace
(
identityKey
)
if
identityKey
==
""
{
return
""
}
sum
:=
sha256
.
Sum256
([]
byte
(
identityKey
))
return
"oidc-"
+
hex
.
EncodeToString
(
sum
[
:
16
])
+
service
.
OIDCConnectSyntheticEmailDomain
}
func
oidcSelectLoginEmail
(
userInfoEmail
,
idTokenEmail
,
identityKey
string
)
string
{
email
:=
strings
.
TrimSpace
(
firstNonEmpty
(
userInfoEmail
,
idTokenEmail
))
if
email
!=
""
{
return
email
}
return
oidcSyntheticEmailFromIdentityKey
(
identityKey
)
}
func
oidcFallbackUsername
(
subject
string
)
string
{
subject
=
strings
.
TrimSpace
(
subject
)
if
subject
==
""
{
return
"oidc_user"
}
sum
:=
sha256
.
Sum256
([]
byte
(
subject
))
return
"oidc_"
+
hex
.
EncodeToString
(
sum
[
:
])[
:
12
]
}
func
oidcSetCookie
(
c
*
gin
.
Context
,
name
,
value
string
,
maxAgeSec
int
,
secure
bool
)
{
http
.
SetCookie
(
c
.
Writer
,
&
http
.
Cookie
{
Name
:
name
,
Value
:
value
,
Path
:
oidcOAuthCookiePath
,
MaxAge
:
maxAgeSec
,
HttpOnly
:
true
,
Secure
:
secure
,
SameSite
:
http
.
SameSiteLaxMode
,
})
}
func
oidcClearCookie
(
c
*
gin
.
Context
,
name
string
,
secure
bool
)
{
http
.
SetCookie
(
c
.
Writer
,
&
http
.
Cookie
{
Name
:
name
,
Value
:
""
,
Path
:
oidcOAuthCookiePath
,
MaxAge
:
-
1
,
HttpOnly
:
true
,
Secure
:
secure
,
SameSite
:
http
.
SameSiteLaxMode
,
})
}
backend/internal/handler/auth_oidc_oauth_test.go
0 → 100644
View file @
a04ae28a
package
handler
import
(
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/require"
)
func
TestOIDCSyntheticEmailStableAndDistinct
(
t
*
testing
.
T
)
{
k1
:=
oidcIdentityKey
(
"https://issuer.example.com"
,
"subject-a"
)
k2
:=
oidcIdentityKey
(
"https://issuer.example.com"
,
"subject-b"
)
e1
:=
oidcSyntheticEmailFromIdentityKey
(
k1
)
e1Again
:=
oidcSyntheticEmailFromIdentityKey
(
k1
)
e2
:=
oidcSyntheticEmailFromIdentityKey
(
k2
)
require
.
Equal
(
t
,
e1
,
e1Again
)
require
.
NotEqual
(
t
,
e1
,
e2
)
require
.
Contains
(
t
,
e1
,
"@oidc-connect.invalid"
)
}
func
TestOIDCSelectLoginEmailPrefersRealEmail
(
t
*
testing
.
T
)
{
identityKey
:=
oidcIdentityKey
(
"https://issuer.example.com"
,
"subject-a"
)
email
:=
oidcSelectLoginEmail
(
"user@example.com"
,
"idtoken@example.com"
,
identityKey
)
require
.
Equal
(
t
,
"user@example.com"
,
email
)
email
=
oidcSelectLoginEmail
(
""
,
"idtoken@example.com"
,
identityKey
)
require
.
Equal
(
t
,
"idtoken@example.com"
,
email
)
email
=
oidcSelectLoginEmail
(
""
,
""
,
identityKey
)
require
.
Contains
(
t
,
email
,
"@oidc-connect.invalid"
)
require
.
Equal
(
t
,
oidcSyntheticEmailFromIdentityKey
(
identityKey
),
email
)
}
func
TestBuildOIDCAuthorizeURLIncludesNonceAndPKCE
(
t
*
testing
.
T
)
{
cfg
:=
config
.
OIDCConnectConfig
{
AuthorizeURL
:
"https://issuer.example.com/auth"
,
ClientID
:
"cid"
,
Scopes
:
"openid email profile"
,
UsePKCE
:
true
,
}
u
,
err
:=
buildOIDCAuthorizeURL
(
cfg
,
"state123"
,
"nonce123"
,
"challenge123"
,
"https://app.example.com/callback"
)
require
.
NoError
(
t
,
err
)
require
.
Contains
(
t
,
u
,
"nonce=nonce123"
)
require
.
Contains
(
t
,
u
,
"code_challenge=challenge123"
)
require
.
Contains
(
t
,
u
,
"code_challenge_method=S256"
)
require
.
Contains
(
t
,
u
,
"scope=openid+email+profile"
)
}
func
TestOIDCParseAndValidateIDToken
(
t
*
testing
.
T
)
{
priv
,
err
:=
rsa
.
GenerateKey
(
rand
.
Reader
,
2048
)
require
.
NoError
(
t
,
err
)
kid
:=
"kid-1"
jwks
:=
oidcJWKSet
{
Keys
:
[]
oidcJWK
{
buildRSAJWK
(
kid
,
&
priv
.
PublicKey
)}}
srv
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
require
.
NoError
(
t
,
json
.
NewEncoder
(
w
)
.
Encode
(
jwks
))
}))
defer
srv
.
Close
()
now
:=
time
.
Now
()
claims
:=
oidcIDTokenClaims
{
Nonce
:
"nonce-ok"
,
Azp
:
"client-1"
,
RegisteredClaims
:
jwt
.
RegisteredClaims
{
Issuer
:
"https://issuer.example.com"
,
Subject
:
"subject-1"
,
Audience
:
jwt
.
ClaimStrings
{
"client-1"
,
"another-aud"
},
IssuedAt
:
jwt
.
NewNumericDate
(
now
),
NotBefore
:
jwt
.
NewNumericDate
(
now
.
Add
(
-
30
*
time
.
Second
)),
ExpiresAt
:
jwt
.
NewNumericDate
(
now
.
Add
(
5
*
time
.
Minute
)),
},
}
tok
:=
jwt
.
NewWithClaims
(
jwt
.
SigningMethodRS256
,
claims
)
tok
.
Header
[
"kid"
]
=
kid
signed
,
err
:=
tok
.
SignedString
(
priv
)
require
.
NoError
(
t
,
err
)
cfg
:=
config
.
OIDCConnectConfig
{
ClientID
:
"client-1"
,
IssuerURL
:
"https://issuer.example.com"
,
JWKSURL
:
srv
.
URL
,
AllowedSigningAlgs
:
"RS256"
,
ClockSkewSeconds
:
120
,
}
parsed
,
err
:=
oidcParseAndValidateIDToken
(
context
.
Background
(),
cfg
,
signed
,
"nonce-ok"
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"subject-1"
,
parsed
.
Subject
)
require
.
Equal
(
t
,
"https://issuer.example.com"
,
parsed
.
Issuer
)
_
,
err
=
oidcParseAndValidateIDToken
(
context
.
Background
(),
cfg
,
signed
,
"bad-nonce"
)
require
.
Error
(
t
,
err
)
}
func
buildRSAJWK
(
kid
string
,
pub
*
rsa
.
PublicKey
)
oidcJWK
{
n
:=
base64
.
RawURLEncoding
.
EncodeToString
(
pub
.
N
.
Bytes
())
e
:=
base64
.
RawURLEncoding
.
EncodeToString
(
big
.
NewInt
(
int64
(
pub
.
E
))
.
Bytes
())
return
oidcJWK
{
Kty
:
"RSA"
,
Kid
:
kid
,
Use
:
"sig"
,
Alg
:
"RS256"
,
N
:
n
,
E
:
e
,
}
}
backend/internal/handler/dto/mappers.go
View file @
a04ae28a
...
...
@@ -138,6 +138,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
ModelRoutingEnabled
:
g
.
ModelRoutingEnabled
,
MCPXMLInject
:
g
.
MCPXMLInject
,
DefaultMappedModel
:
g
.
DefaultMappedModel
,
MessagesDispatchModelConfig
:
g
.
MessagesDispatchModelConfig
,
SupportedModelScopes
:
g
.
SupportedModelScopes
,
AccountCount
:
g
.
AccountCount
,
ActiveAccountCount
:
g
.
ActiveAccountCount
,
...
...
backend/internal/handler/dto/settings.go
View file @
a04ae28a
...
...
@@ -51,6 +51,29 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured
bool
`json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectRedirectURL
string
`json:"linuxdo_connect_redirect_url"`
OIDCConnectEnabled
bool
`json:"oidc_connect_enabled"`
OIDCConnectProviderName
string
`json:"oidc_connect_provider_name"`
OIDCConnectClientID
string
`json:"oidc_connect_client_id"`
OIDCConnectClientSecretConfigured
bool
`json:"oidc_connect_client_secret_configured"`
OIDCConnectIssuerURL
string
`json:"oidc_connect_issuer_url"`
OIDCConnectDiscoveryURL
string
`json:"oidc_connect_discovery_url"`
OIDCConnectAuthorizeURL
string
`json:"oidc_connect_authorize_url"`
OIDCConnectTokenURL
string
`json:"oidc_connect_token_url"`
OIDCConnectUserInfoURL
string
`json:"oidc_connect_userinfo_url"`
OIDCConnectJWKSURL
string
`json:"oidc_connect_jwks_url"`
OIDCConnectScopes
string
`json:"oidc_connect_scopes"`
OIDCConnectRedirectURL
string
`json:"oidc_connect_redirect_url"`
OIDCConnectFrontendRedirectURL
string
`json:"oidc_connect_frontend_redirect_url"`
OIDCConnectTokenAuthMethod
string
`json:"oidc_connect_token_auth_method"`
OIDCConnectUsePKCE
bool
`json:"oidc_connect_use_pkce"`
OIDCConnectValidateIDToken
bool
`json:"oidc_connect_validate_id_token"`
OIDCConnectAllowedSigningAlgs
string
`json:"oidc_connect_allowed_signing_algs"`
OIDCConnectClockSkewSeconds
int
`json:"oidc_connect_clock_skew_seconds"`
OIDCConnectRequireEmailVerified
bool
`json:"oidc_connect_require_email_verified"`
OIDCConnectUserInfoEmailPath
string
`json:"oidc_connect_userinfo_email_path"`
OIDCConnectUserInfoIDPath
string
`json:"oidc_connect_userinfo_id_path"`
OIDCConnectUserInfoUsernamePath
string
`json:"oidc_connect_userinfo_username_path"`
SiteName
string
`json:"site_name"`
SiteLogo
string
`json:"site_logo"`
SiteSubtitle
string
`json:"site_subtitle"`
...
...
@@ -61,6 +84,8 @@ type SystemSettings struct {
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
TableDefaultPageSize
int
`json:"table_default_page_size"`
TablePageSizeOptions
[]
int
`json:"table_page_size_options"`
CustomMenuItems
[]
CustomMenuItem
`json:"custom_menu_items"`
CustomEndpoints
[]
CustomEndpoint
`json:"custom_endpoints"`
...
...
@@ -98,6 +123,28 @@ type SystemSettings struct {
EnableFingerprintUnification
bool
`json:"enable_fingerprint_unification"`
EnableMetadataPassthrough
bool
`json:"enable_metadata_passthrough"`
EnableCCHSigning
bool
`json:"enable_cch_signing"`
// Payment configuration
PaymentEnabled
bool
`json:"payment_enabled"`
PaymentMinAmount
float64
`json:"payment_min_amount"`
PaymentMaxAmount
float64
`json:"payment_max_amount"`
PaymentDailyLimit
float64
`json:"payment_daily_limit"`
PaymentOrderTimeoutMin
int
`json:"payment_order_timeout_minutes"`
PaymentMaxPendingOrders
int
`json:"payment_max_pending_orders"`
PaymentEnabledTypes
[]
string
`json:"payment_enabled_types"`
PaymentBalanceDisabled
bool
`json:"payment_balance_disabled"`
PaymentLoadBalanceStrat
string
`json:"payment_load_balance_strategy"`
PaymentProductNamePrefix
string
`json:"payment_product_name_prefix"`
PaymentProductNameSuffix
string
`json:"payment_product_name_suffix"`
PaymentHelpImageURL
string
`json:"payment_help_image_url"`
PaymentHelpText
string
`json:"payment_help_text"`
// Cancel rate limit
PaymentCancelRateLimitEnabled
bool
`json:"payment_cancel_rate_limit_enabled"`
PaymentCancelRateLimitMax
int
`json:"payment_cancel_rate_limit_max"`
PaymentCancelRateLimitWindow
int
`json:"payment_cancel_rate_limit_window"`
PaymentCancelRateLimitUnit
string
`json:"payment_cancel_rate_limit_unit"`
PaymentCancelRateLimitMode
string
`json:"payment_cancel_rate_limit_window_mode"`
}
type
DefaultSubscriptionSetting
struct
{
...
...
@@ -125,10 +172,16 @@ type PublicSettings struct {
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url"`
TableDefaultPageSize
int
`json:"table_default_page_size"`
TablePageSizeOptions
[]
int
`json:"table_page_size_options"`
CustomMenuItems
[]
CustomMenuItem
`json:"custom_menu_items"`
CustomEndpoints
[]
CustomEndpoint
`json:"custom_endpoints"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
OIDCOAuthEnabled
bool
`json:"oidc_oauth_enabled"`
OIDCOAuthProviderName
string
`json:"oidc_oauth_provider_name"`
SoraClientEnabled
bool
`json:"sora_client_enabled"`
BackendModeEnabled
bool
`json:"backend_mode_enabled"`
PaymentEnabled
bool
`json:"payment_enabled"`
Version
string
`json:"version"`
}
...
...
backend/internal/handler/dto/types.go
View file @
a04ae28a
package
dto
import
"time"
import
(
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
type
User
struct
{
ID
int64
`json:"id"`
...
...
@@ -113,6 +117,7 @@ type AdminGroup struct {
// OpenAI Messages 调度配置(仅 openai 平台使用)
DefaultMappedModel
string
`json:"default_mapped_model"`
MessagesDispatchModelConfig
domain
.
OpenAIMessagesDispatchModelConfig
`json:"messages_dispatch_model_config"`
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes
[]
string
`json:"supported_model_scopes"`
...
...
backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go
View file @
a04ae28a
...
...
@@ -34,7 +34,12 @@ func (f *fakeSchedulerCache) GetSnapshot(_ context.Context, _ service.SchedulerB
func
(
f
*
fakeSchedulerCache
)
SetSnapshot
(
_
context
.
Context
,
_
service
.
SchedulerBucket
,
_
[]
service
.
Account
)
error
{
return
nil
}
func
(
f
*
fakeSchedulerCache
)
GetAccount
(
_
context
.
Context
,
_
int64
)
(
*
service
.
Account
,
error
)
{
func
(
f
*
fakeSchedulerCache
)
GetAccount
(
_
context
.
Context
,
id
int64
)
(
*
service
.
Account
,
error
)
{
for
_
,
account
:=
range
f
.
accounts
{
if
account
!=
nil
&&
account
.
ID
==
id
{
return
account
,
nil
}
}
return
nil
,
nil
}
func
(
f
*
fakeSchedulerCache
)
SetAccount
(
_
context
.
Context
,
_
*
service
.
Account
)
error
{
return
nil
}
...
...
backend/internal/handler/handler.go
View file @
a04ae28a
...
...
@@ -31,6 +31,7 @@ type AdminHandlers struct {
APIKey
*
admin
.
AdminAPIKeyHandler
ScheduledTest
*
admin
.
ScheduledTestHandler
Channel
*
admin
.
ChannelHandler
Payment
*
admin
.
PaymentHandler
}
// Handlers contains all HTTP handlers
...
...
@@ -47,6 +48,8 @@ type Handlers struct {
OpenAIGateway
*
OpenAIGatewayHandler
Setting
*
SettingHandler
Totp
*
TotpHandler
Payment
*
PaymentHandler
PaymentWebhook
*
PaymentWebhookHandler
}
// BuildInfo contains build-time information
...
...
backend/internal/handler/openai_gateway_handler.go
View file @
a04ae28a
...
...
@@ -47,6 +47,13 @@ func resolveOpenAIForwardDefaultMappedModel(apiKey *service.APIKey, fallbackMode
return
strings
.
TrimSpace
(
apiKey
.
Group
.
DefaultMappedModel
)
}
func
resolveOpenAIMessagesDispatchMappedModel
(
apiKey
*
service
.
APIKey
,
requestedModel
string
)
string
{
if
apiKey
==
nil
||
apiKey
.
Group
==
nil
{
return
""
}
return
strings
.
TrimSpace
(
apiKey
.
Group
.
ResolveMessagesDispatchModel
(
requestedModel
))
}
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
func
NewOpenAIGatewayHandler
(
gatewayService
*
service
.
OpenAIGatewayService
,
...
...
@@ -551,6 +558,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}
reqModel
:=
modelResult
.
String
()
routingModel
:=
service
.
NormalizeOpenAICompatRequestedModel
(
reqModel
)
preferredMappedModel
:=
resolveOpenAIMessagesDispatchMappedModel
(
apiKey
,
reqModel
)
reqStream
:=
gjson
.
GetBytes
(
body
,
"stream"
)
.
Bool
()
reqLog
=
reqLog
.
With
(
zap
.
String
(
"model"
,
reqModel
),
zap
.
Bool
(
"stream"
,
reqStream
))
...
...
@@ -609,17 +617,20 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
failedAccountIDs
:=
make
(
map
[
int64
]
struct
{})
sameAccountRetryCount
:=
make
(
map
[
int64
]
int
)
var
lastFailoverErr
*
service
.
UpstreamFailoverError
effectiveMappedModel
:=
preferredMappedModel
for
{
// 清除上一次迭代的降级模型标记,避免残留影响本次迭代
c
.
Set
(
"openai_messages_fallback_model"
,
""
)
currentRoutingModel
:=
routingModel
if
effectiveMappedModel
!=
""
{
currentRoutingModel
=
effectiveMappedModel
}
reqLog
.
Debug
(
"openai_messages.account_selecting"
,
zap
.
Int
(
"excluded_account_count"
,
len
(
failedAccountIDs
)))
selection
,
scheduleDecision
,
err
:=
h
.
gatewayService
.
SelectAccountWithScheduler
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
""
,
// no previous_response_id
sessionHash
,
r
outingModel
,
currentR
outingModel
,
failedAccountIDs
,
service
.
OpenAIUpstreamTransportAny
,
)
...
...
@@ -628,29 +639,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
zap
.
Error
(
err
),
zap
.
Int
(
"excluded_account_count"
,
len
(
failedAccountIDs
)),
)
// 首次调度失败 + 有默认映射模型 → 用默认模型重试
if
len
(
failedAccountIDs
)
==
0
{
defaultModel
:=
""
if
apiKey
.
Group
!=
nil
{
defaultModel
=
apiKey
.
Group
.
DefaultMappedModel
}
if
defaultModel
!=
""
&&
defaultModel
!=
routingModel
{
reqLog
.
Info
(
"openai_messages.fallback_to_default_model"
,
zap
.
String
(
"default_mapped_model"
,
defaultModel
),
)
selection
,
scheduleDecision
,
err
=
h
.
gatewayService
.
SelectAccountWithScheduler
(
c
.
Request
.
Context
(),
apiKey
.
GroupID
,
""
,
sessionHash
,
defaultModel
,
failedAccountIDs
,
service
.
OpenAIUpstreamTransportAny
,
)
if
err
==
nil
&&
selection
!=
nil
{
c
.
Set
(
"openai_messages_fallback_model"
,
defaultModel
)
}
}
if
err
!=
nil
{
h
.
anthropicStreamingAwareError
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"Service temporarily unavailable"
,
streamStarted
)
return
...
...
@@ -682,9 +671,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
service
.
SetOpsLatencyMs
(
c
,
service
.
OpsRoutingLatencyMsKey
,
time
.
Since
(
routingStart
)
.
Milliseconds
())
forwardStart
:=
time
.
Now
()
// Forward 层需要始终拿到 group 默认映射模型,这样未命中账号级映射的
// Claude 兼容模型才不会在后续 Codex 规范化中意外退化到 gpt-5.1。
defaultMappedModel
:=
resolveOpenAIForwardDefaultMappedModel
(
apiKey
,
c
.
GetString
(
"openai_messages_fallback_model"
))
defaultMappedModel
:=
strings
.
TrimSpace
(
effectiveMappedModel
)
// 应用渠道模型映射到请求体
forwardBody
:=
body
if
channelMappingMsg
.
Mapped
{
...
...
backend/internal/handler/openai_gateway_handler_test.go
View file @
a04ae28a
...
...
@@ -360,7 +360,7 @@ func TestResolveOpenAIForwardDefaultMappedModel(t *testing.T) {
require
.
Equal
(
t
,
"gpt-5.2"
,
resolveOpenAIForwardDefaultMappedModel
(
apiKey
,
" gpt-5.2 "
))
})
t
.
Run
(
"uses_group_default_
on_normal_path
"
,
func
(
t
*
testing
.
T
)
{
t
.
Run
(
"uses_group_default_
when_explicit_fallback_absent
"
,
func
(
t
*
testing
.
T
)
{
apiKey
:=
&
service
.
APIKey
{
Group
:
&
service
.
Group
{
DefaultMappedModel
:
"gpt-5.4"
},
}
...
...
@@ -376,6 +376,45 @@ func TestResolveOpenAIForwardDefaultMappedModel(t *testing.T) {
})
}
func
TestResolveOpenAIMessagesDispatchMappedModel
(
t
*
testing
.
T
)
{
t
.
Run
(
"exact_claude_model_override_wins"
,
func
(
t
*
testing
.
T
)
{
apiKey
:=
&
service
.
APIKey
{
Group
:
&
service
.
Group
{
MessagesDispatchModelConfig
:
service
.
OpenAIMessagesDispatchModelConfig
{
SonnetMappedModel
:
"gpt-5.2"
,
ExactModelMappings
:
map
[
string
]
string
{
"claude-sonnet-4-5-20250929"
:
"gpt-5.4-mini-high"
,
},
},
},
}
require
.
Equal
(
t
,
"gpt-5.4-mini"
,
resolveOpenAIMessagesDispatchMappedModel
(
apiKey
,
"claude-sonnet-4-5-20250929"
))
})
t
.
Run
(
"uses_family_default_when_no_override"
,
func
(
t
*
testing
.
T
)
{
apiKey
:=
&
service
.
APIKey
{
Group
:
&
service
.
Group
{}}
require
.
Equal
(
t
,
"gpt-5.4"
,
resolveOpenAIMessagesDispatchMappedModel
(
apiKey
,
"claude-opus-4-6"
))
require
.
Equal
(
t
,
"gpt-5.3-codex"
,
resolveOpenAIMessagesDispatchMappedModel
(
apiKey
,
"claude-sonnet-4-5-20250929"
))
require
.
Equal
(
t
,
"gpt-5.4-mini"
,
resolveOpenAIMessagesDispatchMappedModel
(
apiKey
,
"claude-haiku-4-5-20251001"
))
})
t
.
Run
(
"returns_empty_for_non_claude_or_missing_group"
,
func
(
t
*
testing
.
T
)
{
require
.
Empty
(
t
,
resolveOpenAIMessagesDispatchMappedModel
(
nil
,
"claude-sonnet-4-5-20250929"
))
require
.
Empty
(
t
,
resolveOpenAIMessagesDispatchMappedModel
(
&
service
.
APIKey
{},
"claude-sonnet-4-5-20250929"
))
require
.
Empty
(
t
,
resolveOpenAIMessagesDispatchMappedModel
(
&
service
.
APIKey
{
Group
:
&
service
.
Group
{}},
"gpt-5.4"
))
})
t
.
Run
(
"does_not_fall_back_to_group_default_mapped_model"
,
func
(
t
*
testing
.
T
)
{
apiKey
:=
&
service
.
APIKey
{
Group
:
&
service
.
Group
{
DefaultMappedModel
:
"gpt-5.4"
,
},
}
require
.
Empty
(
t
,
resolveOpenAIMessagesDispatchMappedModel
(
apiKey
,
"gpt-5.4"
))
require
.
Equal
(
t
,
"gpt-5.3-codex"
,
resolveOpenAIMessagesDispatchMappedModel
(
apiKey
,
"claude-sonnet-4-5-20250929"
))
})
}
func
TestOpenAIResponses_MissingDependencies_ReturnsServiceUnavailable
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
...
...
backend/internal/handler/payment_handler.go
0 → 100644
View file @
a04ae28a
package
handler
import
(
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// PaymentHandler handles user-facing payment requests.
type
PaymentHandler
struct
{
channelService
*
service
.
ChannelService
paymentService
*
service
.
PaymentService
configService
*
service
.
PaymentConfigService
}
// NewPaymentHandler creates a new PaymentHandler.
func
NewPaymentHandler
(
paymentService
*
service
.
PaymentService
,
configService
*
service
.
PaymentConfigService
,
channelService
*
service
.
ChannelService
)
*
PaymentHandler
{
return
&
PaymentHandler
{
channelService
:
channelService
,
paymentService
:
paymentService
,
configService
:
configService
,
}
}
// GetPaymentConfig returns the payment system configuration.
// GET /api/v1/payment/config
func
(
h
*
PaymentHandler
)
GetPaymentConfig
(
c
*
gin
.
Context
)
{
cfg
,
err
:=
h
.
configService
.
GetPaymentConfig
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
cfg
)
}
// GetPlans returns subscription plans available for sale.
// GET /api/v1/payment/plans
func
(
h
*
PaymentHandler
)
GetPlans
(
c
*
gin
.
Context
)
{
plans
,
err
:=
h
.
configService
.
ListPlansForSale
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Enrich plans with group platform for frontend color coding
type
planWithPlatform
struct
{
ID
int64
`json:"id"`
GroupID
int64
`json:"group_id"`
GroupPlatform
string
`json:"group_platform"`
Name
string
`json:"name"`
Description
string
`json:"description"`
Price
float64
`json:"price"`
OriginalPrice
*
float64
`json:"original_price,omitempty"`
ValidityDays
int
`json:"validity_days"`
ValidityUnit
string
`json:"validity_unit"`
Features
string
`json:"features"`
ProductName
string
`json:"product_name"`
ForSale
bool
`json:"for_sale"`
SortOrder
int
`json:"sort_order"`
}
platformMap
:=
h
.
configService
.
GetGroupPlatformMap
(
c
.
Request
.
Context
(),
plans
)
result
:=
make
([]
planWithPlatform
,
0
,
len
(
plans
))
for
_
,
p
:=
range
plans
{
result
=
append
(
result
,
planWithPlatform
{
ID
:
int64
(
p
.
ID
),
GroupID
:
p
.
GroupID
,
GroupPlatform
:
platformMap
[
p
.
GroupID
],
Name
:
p
.
Name
,
Description
:
p
.
Description
,
Price
:
p
.
Price
,
OriginalPrice
:
p
.
OriginalPrice
,
ValidityDays
:
p
.
ValidityDays
,
ValidityUnit
:
p
.
ValidityUnit
,
Features
:
p
.
Features
,
ProductName
:
p
.
ProductName
,
ForSale
:
p
.
ForSale
,
SortOrder
:
p
.
SortOrder
,
})
}
response
.
Success
(
c
,
result
)
}
// GetChannels returns enabled payment channels.
// GET /api/v1/payment/channels
func
(
h
*
PaymentHandler
)
GetChannels
(
c
*
gin
.
Context
)
{
channels
,
_
,
err
:=
h
.
channelService
.
List
(
c
.
Request
.
Context
(),
pagination
.
PaginationParams
{
Page
:
1
,
PageSize
:
1000
},
"active"
,
""
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
channels
)
}
// GetCheckoutInfo returns all data the payment page needs in a single call:
// payment methods with limits, subscription plans, and configuration.
// GET /api/v1/payment/checkout-info
func
(
h
*
PaymentHandler
)
GetCheckoutInfo
(
c
*
gin
.
Context
)
{
ctx
:=
c
.
Request
.
Context
()
// Fetch limits (methods + global range)
limitsResp
,
err
:=
h
.
configService
.
GetAvailableMethodLimits
(
ctx
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Fetch payment config
cfg
,
err
:=
h
.
configService
.
GetPaymentConfig
(
ctx
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
// Fetch plans with group info
plans
,
_
:=
h
.
configService
.
ListPlansForSale
(
ctx
)
groupInfo
:=
h
.
configService
.
GetGroupInfoMap
(
ctx
,
plans
)
planList
:=
make
([]
checkoutPlan
,
0
,
len
(
plans
))
for
_
,
p
:=
range
plans
{
gi
:=
groupInfo
[
p
.
GroupID
]
planList
=
append
(
planList
,
checkoutPlan
{
ID
:
int64
(
p
.
ID
),
GroupID
:
p
.
GroupID
,
GroupPlatform
:
gi
.
Platform
,
GroupName
:
gi
.
Name
,
RateMultiplier
:
gi
.
RateMultiplier
,
DailyLimitUSD
:
gi
.
DailyLimitUSD
,
WeeklyLimitUSD
:
gi
.
WeeklyLimitUSD
,
MonthlyLimitUSD
:
gi
.
MonthlyLimitUSD
,
ModelScopes
:
gi
.
ModelScopes
,
Name
:
p
.
Name
,
Description
:
p
.
Description
,
Price
:
p
.
Price
,
OriginalPrice
:
p
.
OriginalPrice
,
ValidityDays
:
p
.
ValidityDays
,
ValidityUnit
:
p
.
ValidityUnit
,
Features
:
parseFeatures
(
p
.
Features
),
ProductName
:
p
.
ProductName
,
})
}
response
.
Success
(
c
,
checkoutInfoResponse
{
Methods
:
limitsResp
.
Methods
,
GlobalMin
:
limitsResp
.
GlobalMin
,
GlobalMax
:
limitsResp
.
GlobalMax
,
Plans
:
planList
,
BalanceDisabled
:
cfg
.
BalanceDisabled
,
HelpText
:
cfg
.
HelpText
,
HelpImageURL
:
cfg
.
HelpImageURL
,
StripePublishableKey
:
cfg
.
StripePublishableKey
,
})
}
type
checkoutInfoResponse
struct
{
Methods
map
[
string
]
service
.
MethodLimits
`json:"methods"`
GlobalMin
float64
`json:"global_min"`
GlobalMax
float64
`json:"global_max"`
Plans
[]
checkoutPlan
`json:"plans"`
BalanceDisabled
bool
`json:"balance_disabled"`
HelpText
string
`json:"help_text"`
HelpImageURL
string
`json:"help_image_url"`
StripePublishableKey
string
`json:"stripe_publishable_key"`
}
type
checkoutPlan
struct
{
ID
int64
`json:"id"`
GroupID
int64
`json:"group_id"`
GroupPlatform
string
`json:"group_platform"`
GroupName
string
`json:"group_name"`
RateMultiplier
float64
`json:"rate_multiplier"`
DailyLimitUSD
*
float64
`json:"daily_limit_usd"`
WeeklyLimitUSD
*
float64
`json:"weekly_limit_usd"`
MonthlyLimitUSD
*
float64
`json:"monthly_limit_usd"`
ModelScopes
[]
string
`json:"supported_model_scopes"`
Name
string
`json:"name"`
Description
string
`json:"description"`
Price
float64
`json:"price"`
OriginalPrice
*
float64
`json:"original_price,omitempty"`
ValidityDays
int
`json:"validity_days"`
ValidityUnit
string
`json:"validity_unit"`
Features
[]
string
`json:"features"`
ProductName
string
`json:"product_name"`
}
// parseFeatures splits a newline-separated features string into a string slice.
func
parseFeatures
(
raw
string
)
[]
string
{
if
raw
==
""
{
return
[]
string
{}
}
var
out
[]
string
for
_
,
line
:=
range
strings
.
Split
(
raw
,
"
\n
"
)
{
if
s
:=
strings
.
TrimSpace
(
line
);
s
!=
""
{
out
=
append
(
out
,
s
)
}
}
if
out
==
nil
{
return
[]
string
{}
}
return
out
}
// GetLimits returns per-payment-type limits derived from enabled provider instances.
// GET /api/v1/payment/limits
func
(
h
*
PaymentHandler
)
GetLimits
(
c
*
gin
.
Context
)
{
resp
,
err
:=
h
.
configService
.
GetAvailableMethodLimits
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
resp
)
}
// CreateOrderRequest is the request body for creating a payment order.
type
CreateOrderRequest
struct
{
Amount
float64
`json:"amount"`
PaymentType
string
`json:"payment_type" binding:"required"`
OrderType
string
`json:"order_type"`
PlanID
int64
`json:"plan_id"`
}
// CreateOrder creates a new payment order.
// POST /api/v1/payment/orders
func
(
h
*
PaymentHandler
)
CreateOrder
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
requireAuth
(
c
)
if
!
ok
{
return
}
var
req
CreateOrderRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
result
,
err
:=
h
.
paymentService
.
CreateOrder
(
c
.
Request
.
Context
(),
service
.
CreateOrderRequest
{
UserID
:
subject
.
UserID
,
Amount
:
req
.
Amount
,
PaymentType
:
req
.
PaymentType
,
ClientIP
:
c
.
ClientIP
(),
IsMobile
:
isMobile
(
c
),
SrcHost
:
c
.
Request
.
Host
,
SrcURL
:
c
.
Request
.
Referer
(),
OrderType
:
req
.
OrderType
,
PlanID
:
req
.
PlanID
,
})
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
result
)
}
// GetMyOrders returns the authenticated user's orders.
// GET /api/v1/payment/orders/my
func
(
h
*
PaymentHandler
)
GetMyOrders
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
requireAuth
(
c
)
if
!
ok
{
return
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
orders
,
total
,
err
:=
h
.
paymentService
.
GetUserOrders
(
c
.
Request
.
Context
(),
subject
.
UserID
,
service
.
OrderListParams
{
Page
:
page
,
PageSize
:
pageSize
,
Status
:
c
.
Query
(
"status"
),
OrderType
:
c
.
Query
(
"order_type"
),
PaymentType
:
c
.
Query
(
"payment_type"
),
})
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Paginated
(
c
,
orders
,
int64
(
total
),
page
,
pageSize
)
}
// GetOrder returns a single order for the authenticated user.
// GET /api/v1/payment/orders/:id
func
(
h
*
PaymentHandler
)
GetOrder
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
requireAuth
(
c
)
if
!
ok
{
return
}
orderID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid order ID"
)
return
}
order
,
err
:=
h
.
paymentService
.
GetOrder
(
c
.
Request
.
Context
(),
orderID
,
subject
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
order
)
}
// CancelOrder cancels a pending order for the authenticated user.
// POST /api/v1/payment/orders/:id/cancel
func
(
h
*
PaymentHandler
)
CancelOrder
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
requireAuth
(
c
)
if
!
ok
{
return
}
orderID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid order ID"
)
return
}
msg
,
err
:=
h
.
paymentService
.
CancelOrder
(
c
.
Request
.
Context
(),
orderID
,
subject
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"message"
:
msg
})
}
// RefundRequestBody is the request body for requesting a refund.
type
RefundRequestBody
struct
{
Reason
string
`json:"reason"`
}
// RequestRefund submits a refund request for a completed order.
// POST /api/v1/payment/orders/:id/refund-request
func
(
h
*
PaymentHandler
)
RequestRefund
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
requireAuth
(
c
)
if
!
ok
{
return
}
orderID
,
err
:=
strconv
.
ParseInt
(
c
.
Param
(
"id"
),
10
,
64
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid order ID"
)
return
}
var
req
RefundRequestBody
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
if
err
:=
h
.
paymentService
.
RequestRefund
(
c
.
Request
.
Context
(),
orderID
,
subject
.
UserID
,
req
.
Reason
);
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"message"
:
"refund requested"
})
}
// VerifyOrderRequest is the request body for verifying a payment order.
type
VerifyOrderRequest
struct
{
OutTradeNo
string
`json:"out_trade_no" binding:"required"`
}
// VerifyOrder actively queries the upstream payment provider to check
// if payment was made, and processes it if so.
// POST /api/v1/payment/orders/verify
func
(
h
*
PaymentHandler
)
VerifyOrder
(
c
*
gin
.
Context
)
{
subject
,
ok
:=
requireAuth
(
c
)
if
!
ok
{
return
}
var
req
VerifyOrderRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
order
,
err
:=
h
.
paymentService
.
VerifyOrderByOutTradeNo
(
c
.
Request
.
Context
(),
req
.
OutTradeNo
,
subject
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
order
)
}
// PublicOrderResult is the limited order info returned by the public verify endpoint.
// No user details are exposed — only payment status information.
type
PublicOrderResult
struct
{
ID
int64
`json:"id"`
OutTradeNo
string
`json:"out_trade_no"`
Amount
float64
`json:"amount"`
PayAmount
float64
`json:"pay_amount"`
PaymentType
string
`json:"payment_type"`
Status
string
`json:"status"`
}
// VerifyOrderPublic verifies payment status without requiring authentication.
// Returns limited order info (no user details) to prevent information leakage.
// POST /api/v1/payment/public/orders/verify
func
(
h
*
PaymentHandler
)
VerifyOrderPublic
(
c
*
gin
.
Context
)
{
var
req
VerifyOrderRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request: "
+
err
.
Error
())
return
}
order
,
err
:=
h
.
paymentService
.
VerifyOrderPublic
(
c
.
Request
.
Context
(),
req
.
OutTradeNo
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
PublicOrderResult
{
ID
:
order
.
ID
,
OutTradeNo
:
order
.
OutTradeNo
,
Amount
:
order
.
Amount
,
PayAmount
:
order
.
PayAmount
,
PaymentType
:
order
.
PaymentType
,
Status
:
order
.
Status
,
})
}
// requireAuth extracts the authenticated subject from the context.
// Returns the subject and true on success; on failure it writes an Unauthorized response and returns false.
func
requireAuth
(
c
*
gin
.
Context
)
(
middleware2
.
AuthSubject
,
bool
)
{
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
{
response
.
Unauthorized
(
c
,
"User not authenticated"
)
return
middleware2
.
AuthSubject
{},
false
}
return
subject
,
true
}
// isMobile detects mobile user agents.
func
isMobile
(
c
*
gin
.
Context
)
bool
{
ua
:=
strings
.
ToLower
(
c
.
GetHeader
(
"User-Agent"
))
for
_
,
kw
:=
range
[]
string
{
"mobile"
,
"android"
,
"iphone"
,
"ipad"
,
"ipod"
}
{
if
strings
.
Contains
(
ua
,
kw
)
{
return
true
}
}
return
false
}
Prev
1
2
3
4
5
6
7
8
9
…
16
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment