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/service/setting_service.go
View file @
a04ae28a
...
...
@@ -9,6 +9,7 @@ import (
"fmt"
"log/slog"
"net/url"
"sort"
"strconv"
"strings"
"sync/atomic"
...
...
@@ -16,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/imroc/req/v3"
"golang.org/x/sync/singleflight"
)
...
...
@@ -160,10 +162,15 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyHideCcsImportButton
,
SettingKeyPurchaseSubscriptionEnabled
,
SettingKeyPurchaseSubscriptionURL
,
SettingKeyTableDefaultPageSize
,
SettingKeyTablePageSizeOptions
,
SettingKeyCustomMenuItems
,
SettingKeyCustomEndpoints
,
SettingKeyLinuxDoConnectEnabled
,
SettingKeyBackendModeEnabled
,
SettingKeyOIDCConnectEnabled
,
SettingKeyOIDCConnectProviderName
,
SettingPaymentEnabled
,
}
settings
,
err
:=
s
.
settingRepo
.
GetMultiple
(
ctx
,
keys
)
...
...
@@ -177,6 +184,19 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
}
else
{
linuxDoEnabled
=
s
.
cfg
!=
nil
&&
s
.
cfg
.
LinuxDo
.
Enabled
}
oidcEnabled
:=
false
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectEnabled
];
ok
{
oidcEnabled
=
raw
==
"true"
}
else
{
oidcEnabled
=
s
.
cfg
!=
nil
&&
s
.
cfg
.
OIDC
.
Enabled
}
oidcProviderName
:=
strings
.
TrimSpace
(
settings
[
SettingKeyOIDCConnectProviderName
])
if
oidcProviderName
==
""
&&
s
.
cfg
!=
nil
{
oidcProviderName
=
strings
.
TrimSpace
(
s
.
cfg
.
OIDC
.
ProviderName
)
}
if
oidcProviderName
==
""
{
oidcProviderName
=
"OIDC"
}
// Password reset requires email verification to be enabled
emailVerifyEnabled
:=
settings
[
SettingKeyEmailVerifyEnabled
]
==
"true"
...
...
@@ -184,6 +204,10 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
registrationEmailSuffixWhitelist
:=
ParseRegistrationEmailSuffixWhitelist
(
settings
[
SettingKeyRegistrationEmailSuffixWhitelist
],
)
tableDefaultPageSize
,
tablePageSizeOptions
:=
parseTablePreferences
(
settings
[
SettingKeyTableDefaultPageSize
],
settings
[
SettingKeyTablePageSizeOptions
],
)
return
&
PublicSettings
{
RegistrationEnabled
:
settings
[
SettingKeyRegistrationEnabled
]
==
"true"
,
...
...
@@ -205,10 +229,15 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
HideCcsImportButton
:
settings
[
SettingKeyHideCcsImportButton
]
==
"true"
,
PurchaseSubscriptionEnabled
:
settings
[
SettingKeyPurchaseSubscriptionEnabled
]
==
"true"
,
PurchaseSubscriptionURL
:
strings
.
TrimSpace
(
settings
[
SettingKeyPurchaseSubscriptionURL
]),
TableDefaultPageSize
:
tableDefaultPageSize
,
TablePageSizeOptions
:
tablePageSizeOptions
,
CustomMenuItems
:
settings
[
SettingKeyCustomMenuItems
],
CustomEndpoints
:
settings
[
SettingKeyCustomEndpoints
],
LinuxDoOAuthEnabled
:
linuxDoEnabled
,
BackendModeEnabled
:
settings
[
SettingKeyBackendModeEnabled
]
==
"true"
,
OIDCOAuthEnabled
:
oidcEnabled
,
OIDCOAuthProviderName
:
oidcProviderName
,
PaymentEnabled
:
settings
[
SettingPaymentEnabled
]
==
"true"
,
},
nil
}
...
...
@@ -252,10 +281,15 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
HideCcsImportButton
bool
`json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled
bool
`json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL
string
`json:"purchase_subscription_url,omitempty"`
TableDefaultPageSize
int
`json:"table_default_page_size"`
TablePageSizeOptions
[]
int
`json:"table_page_size_options"`
CustomMenuItems
json
.
RawMessage
`json:"custom_menu_items"`
CustomEndpoints
json
.
RawMessage
`json:"custom_endpoints"`
LinuxDoOAuthEnabled
bool
`json:"linuxdo_oauth_enabled"`
BackendModeEnabled
bool
`json:"backend_mode_enabled"`
OIDCOAuthEnabled
bool
`json:"oidc_oauth_enabled"`
OIDCOAuthProviderName
string
`json:"oidc_oauth_provider_name"`
PaymentEnabled
bool
`json:"payment_enabled"`
Version
string
`json:"version,omitempty"`
}{
RegistrationEnabled
:
settings
.
RegistrationEnabled
,
...
...
@@ -277,10 +311,15 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
HideCcsImportButton
:
settings
.
HideCcsImportButton
,
PurchaseSubscriptionEnabled
:
settings
.
PurchaseSubscriptionEnabled
,
PurchaseSubscriptionURL
:
settings
.
PurchaseSubscriptionURL
,
TableDefaultPageSize
:
settings
.
TableDefaultPageSize
,
TablePageSizeOptions
:
settings
.
TablePageSizeOptions
,
CustomMenuItems
:
filterUserVisibleMenuItems
(
settings
.
CustomMenuItems
),
CustomEndpoints
:
safeRawJSONArray
(
settings
.
CustomEndpoints
),
LinuxDoOAuthEnabled
:
settings
.
LinuxDoOAuthEnabled
,
BackendModeEnabled
:
settings
.
BackendModeEnabled
,
OIDCOAuthEnabled
:
settings
.
OIDCOAuthEnabled
,
OIDCOAuthProviderName
:
settings
.
OIDCOAuthProviderName
,
PaymentEnabled
:
settings
.
PaymentEnabled
,
Version
:
s
.
version
,
},
nil
}
...
...
@@ -333,8 +372,8 @@ func safeRawJSONArray(raw string) json.RawMessage {
return
json
.
RawMessage
(
"[]"
)
}
// GetFrameSrcOrigins returns deduplicated http(s) origins from
purchase_subscription_url
// and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
// GetFrameSrcOrigins returns deduplicated http(s) origins from
home_content URL,
//
purchase_subscription_url,
and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
func
(
s
*
SettingService
)
GetFrameSrcOrigins
(
ctx
context
.
Context
)
([]
string
,
error
)
{
settings
,
err
:=
s
.
GetPublicSettings
(
ctx
)
if
err
!=
nil
{
...
...
@@ -353,6 +392,9 @@ func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, erro
}
}
// home content URL (when home_content is set to a URL for iframe embedding)
addOrigin
(
settings
.
HomeContent
)
// purchase subscription URL
if
settings
.
PurchaseSubscriptionEnabled
{
addOrigin
(
settings
.
PurchaseSubscriptionURL
)
...
...
@@ -460,6 +502,32 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates
[
SettingKeyLinuxDoConnectClientSecret
]
=
settings
.
LinuxDoConnectClientSecret
}
// Generic OIDC OAuth 登录
updates
[
SettingKeyOIDCConnectEnabled
]
=
strconv
.
FormatBool
(
settings
.
OIDCConnectEnabled
)
updates
[
SettingKeyOIDCConnectProviderName
]
=
settings
.
OIDCConnectProviderName
updates
[
SettingKeyOIDCConnectClientID
]
=
settings
.
OIDCConnectClientID
updates
[
SettingKeyOIDCConnectIssuerURL
]
=
settings
.
OIDCConnectIssuerURL
updates
[
SettingKeyOIDCConnectDiscoveryURL
]
=
settings
.
OIDCConnectDiscoveryURL
updates
[
SettingKeyOIDCConnectAuthorizeURL
]
=
settings
.
OIDCConnectAuthorizeURL
updates
[
SettingKeyOIDCConnectTokenURL
]
=
settings
.
OIDCConnectTokenURL
updates
[
SettingKeyOIDCConnectUserInfoURL
]
=
settings
.
OIDCConnectUserInfoURL
updates
[
SettingKeyOIDCConnectJWKSURL
]
=
settings
.
OIDCConnectJWKSURL
updates
[
SettingKeyOIDCConnectScopes
]
=
settings
.
OIDCConnectScopes
updates
[
SettingKeyOIDCConnectRedirectURL
]
=
settings
.
OIDCConnectRedirectURL
updates
[
SettingKeyOIDCConnectFrontendRedirectURL
]
=
settings
.
OIDCConnectFrontendRedirectURL
updates
[
SettingKeyOIDCConnectTokenAuthMethod
]
=
settings
.
OIDCConnectTokenAuthMethod
updates
[
SettingKeyOIDCConnectUsePKCE
]
=
strconv
.
FormatBool
(
settings
.
OIDCConnectUsePKCE
)
updates
[
SettingKeyOIDCConnectValidateIDToken
]
=
strconv
.
FormatBool
(
settings
.
OIDCConnectValidateIDToken
)
updates
[
SettingKeyOIDCConnectAllowedSigningAlgs
]
=
settings
.
OIDCConnectAllowedSigningAlgs
updates
[
SettingKeyOIDCConnectClockSkewSeconds
]
=
strconv
.
Itoa
(
settings
.
OIDCConnectClockSkewSeconds
)
updates
[
SettingKeyOIDCConnectRequireEmailVerified
]
=
strconv
.
FormatBool
(
settings
.
OIDCConnectRequireEmailVerified
)
updates
[
SettingKeyOIDCConnectUserInfoEmailPath
]
=
settings
.
OIDCConnectUserInfoEmailPath
updates
[
SettingKeyOIDCConnectUserInfoIDPath
]
=
settings
.
OIDCConnectUserInfoIDPath
updates
[
SettingKeyOIDCConnectUserInfoUsernamePath
]
=
settings
.
OIDCConnectUserInfoUsernamePath
if
settings
.
OIDCConnectClientSecret
!=
""
{
updates
[
SettingKeyOIDCConnectClientSecret
]
=
settings
.
OIDCConnectClientSecret
}
// OEM设置
updates
[
SettingKeySiteName
]
=
settings
.
SiteName
updates
[
SettingKeySiteLogo
]
=
settings
.
SiteLogo
...
...
@@ -471,6 +539,16 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
updates
[
SettingKeyHideCcsImportButton
]
=
strconv
.
FormatBool
(
settings
.
HideCcsImportButton
)
updates
[
SettingKeyPurchaseSubscriptionEnabled
]
=
strconv
.
FormatBool
(
settings
.
PurchaseSubscriptionEnabled
)
updates
[
SettingKeyPurchaseSubscriptionURL
]
=
strings
.
TrimSpace
(
settings
.
PurchaseSubscriptionURL
)
tableDefaultPageSize
,
tablePageSizeOptions
:=
normalizeTablePreferences
(
settings
.
TableDefaultPageSize
,
settings
.
TablePageSizeOptions
,
)
updates
[
SettingKeyTableDefaultPageSize
]
=
strconv
.
Itoa
(
tableDefaultPageSize
)
tablePageSizeOptionsJSON
,
err
:=
json
.
Marshal
(
tablePageSizeOptions
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"marshal table page size options: %w"
,
err
)
}
updates
[
SettingKeyTablePageSizeOptions
]
=
string
(
tablePageSizeOptionsJSON
)
updates
[
SettingKeyCustomMenuItems
]
=
settings
.
CustomMenuItems
updates
[
SettingKeyCustomEndpoints
]
=
settings
.
CustomEndpoints
...
...
@@ -824,8 +902,12 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeySiteLogo
:
""
,
SettingKeyPurchaseSubscriptionEnabled
:
"false"
,
SettingKeyPurchaseSubscriptionURL
:
""
,
SettingKeyTableDefaultPageSize
:
"20"
,
SettingKeyTablePageSizeOptions
:
"[10,20,50,100]"
,
SettingKeyCustomMenuItems
:
"[]"
,
SettingKeyCustomEndpoints
:
"[]"
,
SettingKeyOIDCConnectEnabled
:
"false"
,
SettingKeyOIDCConnectProviderName
:
"OIDC"
,
SettingKeyDefaultConcurrency
:
strconv
.
Itoa
(
s
.
cfg
.
Default
.
UserConcurrency
),
SettingKeyDefaultBalance
:
strconv
.
FormatFloat
(
s
.
cfg
.
Default
.
UserBalance
,
'f'
,
8
,
64
),
SettingKeyDefaultSubscriptions
:
"[]"
,
...
...
@@ -893,6 +975,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
CustomEndpoints
:
settings
[
SettingKeyCustomEndpoints
],
BackendModeEnabled
:
settings
[
SettingKeyBackendModeEnabled
]
==
"true"
,
}
result
.
TableDefaultPageSize
,
result
.
TablePageSizeOptions
=
parseTablePreferences
(
settings
[
SettingKeyTableDefaultPageSize
],
settings
[
SettingKeyTablePageSizeOptions
],
)
// 解析整数类型
if
port
,
err
:=
strconv
.
Atoi
(
settings
[
SettingKeySMTPPort
]);
err
==
nil
{
...
...
@@ -951,6 +1037,138 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}
result
.
LinuxDoConnectClientSecretConfigured
=
result
.
LinuxDoConnectClientSecret
!=
""
// Generic OIDC 设置:
// - 兼容 config.yaml/env
// - 支持后台系统设置覆盖并持久化(存储于 DB)
oidcBase
:=
config
.
OIDCConnectConfig
{}
if
s
.
cfg
!=
nil
{
oidcBase
=
s
.
cfg
.
OIDC
}
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectEnabled
];
ok
{
result
.
OIDCConnectEnabled
=
raw
==
"true"
}
else
{
result
.
OIDCConnectEnabled
=
oidcBase
.
Enabled
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectProviderName
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectProviderName
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectProviderName
=
strings
.
TrimSpace
(
oidcBase
.
ProviderName
)
}
if
result
.
OIDCConnectProviderName
==
""
{
result
.
OIDCConnectProviderName
=
"OIDC"
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectClientID
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectClientID
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectClientID
=
strings
.
TrimSpace
(
oidcBase
.
ClientID
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectIssuerURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectIssuerURL
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectIssuerURL
=
strings
.
TrimSpace
(
oidcBase
.
IssuerURL
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectDiscoveryURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectDiscoveryURL
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectDiscoveryURL
=
strings
.
TrimSpace
(
oidcBase
.
DiscoveryURL
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectAuthorizeURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectAuthorizeURL
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectAuthorizeURL
=
strings
.
TrimSpace
(
oidcBase
.
AuthorizeURL
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectTokenURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectTokenURL
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectTokenURL
=
strings
.
TrimSpace
(
oidcBase
.
TokenURL
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectUserInfoURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectUserInfoURL
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectUserInfoURL
=
strings
.
TrimSpace
(
oidcBase
.
UserInfoURL
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectJWKSURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectJWKSURL
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectJWKSURL
=
strings
.
TrimSpace
(
oidcBase
.
JWKSURL
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectScopes
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectScopes
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectScopes
=
strings
.
TrimSpace
(
oidcBase
.
Scopes
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectRedirectURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectRedirectURL
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectRedirectURL
=
strings
.
TrimSpace
(
oidcBase
.
RedirectURL
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectFrontendRedirectURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectFrontendRedirectURL
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectFrontendRedirectURL
=
strings
.
TrimSpace
(
oidcBase
.
FrontendRedirectURL
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectTokenAuthMethod
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectTokenAuthMethod
=
strings
.
ToLower
(
strings
.
TrimSpace
(
v
))
}
else
{
result
.
OIDCConnectTokenAuthMethod
=
strings
.
ToLower
(
strings
.
TrimSpace
(
oidcBase
.
TokenAuthMethod
))
}
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectUsePKCE
];
ok
{
result
.
OIDCConnectUsePKCE
=
raw
==
"true"
}
else
{
result
.
OIDCConnectUsePKCE
=
oidcBase
.
UsePKCE
}
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectValidateIDToken
];
ok
{
result
.
OIDCConnectValidateIDToken
=
raw
==
"true"
}
else
{
result
.
OIDCConnectValidateIDToken
=
oidcBase
.
ValidateIDToken
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectAllowedSigningAlgs
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
result
.
OIDCConnectAllowedSigningAlgs
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectAllowedSigningAlgs
=
strings
.
TrimSpace
(
oidcBase
.
AllowedSigningAlgs
)
}
clockSkewSet
:=
false
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectClockSkewSeconds
];
ok
&&
strings
.
TrimSpace
(
raw
)
!=
""
{
if
parsed
,
err
:=
strconv
.
Atoi
(
strings
.
TrimSpace
(
raw
));
err
==
nil
{
result
.
OIDCConnectClockSkewSeconds
=
parsed
clockSkewSet
=
true
}
}
if
!
clockSkewSet
{
result
.
OIDCConnectClockSkewSeconds
=
oidcBase
.
ClockSkewSeconds
}
if
!
clockSkewSet
&&
result
.
OIDCConnectClockSkewSeconds
==
0
{
result
.
OIDCConnectClockSkewSeconds
=
120
}
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectRequireEmailVerified
];
ok
{
result
.
OIDCConnectRequireEmailVerified
=
raw
==
"true"
}
else
{
result
.
OIDCConnectRequireEmailVerified
=
oidcBase
.
RequireEmailVerified
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectUserInfoEmailPath
];
ok
{
result
.
OIDCConnectUserInfoEmailPath
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectUserInfoEmailPath
=
strings
.
TrimSpace
(
oidcBase
.
UserInfoEmailPath
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectUserInfoIDPath
];
ok
{
result
.
OIDCConnectUserInfoIDPath
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectUserInfoIDPath
=
strings
.
TrimSpace
(
oidcBase
.
UserInfoIDPath
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectUserInfoUsernamePath
];
ok
{
result
.
OIDCConnectUserInfoUsernamePath
=
strings
.
TrimSpace
(
v
)
}
else
{
result
.
OIDCConnectUserInfoUsernamePath
=
strings
.
TrimSpace
(
oidcBase
.
UserInfoUsernamePath
)
}
result
.
OIDCConnectClientSecret
=
strings
.
TrimSpace
(
settings
[
SettingKeyOIDCConnectClientSecret
])
if
result
.
OIDCConnectClientSecret
==
""
{
result
.
OIDCConnectClientSecret
=
strings
.
TrimSpace
(
oidcBase
.
ClientSecret
)
}
result
.
OIDCConnectClientSecretConfigured
=
result
.
OIDCConnectClientSecret
!=
""
// Model fallback settings
result
.
EnableModelFallback
=
settings
[
SettingKeyEnableModelFallback
]
==
"true"
result
.
FallbackModelAnthropic
=
s
.
getStringOrDefault
(
settings
,
SettingKeyFallbackModelAnthropic
,
"claude-3-5-sonnet-20241022"
)
...
...
@@ -1036,6 +1254,50 @@ func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting {
return
normalized
}
func
parseTablePreferences
(
defaultPageSizeRaw
,
optionsRaw
string
)
(
int
,
[]
int
)
{
defaultPageSize
:=
20
if
v
,
err
:=
strconv
.
Atoi
(
strings
.
TrimSpace
(
defaultPageSizeRaw
));
err
==
nil
{
defaultPageSize
=
v
}
var
options
[]
int
if
strings
.
TrimSpace
(
optionsRaw
)
!=
""
{
_
=
json
.
Unmarshal
([]
byte
(
optionsRaw
),
&
options
)
}
return
normalizeTablePreferences
(
defaultPageSize
,
options
)
}
func
normalizeTablePreferences
(
defaultPageSize
int
,
options
[]
int
)
(
int
,
[]
int
)
{
const
minPageSize
=
5
const
maxPageSize
=
1000
const
fallbackPageSize
=
20
seen
:=
make
(
map
[
int
]
struct
{},
len
(
options
))
normalizedOptions
:=
make
([]
int
,
0
,
len
(
options
))
for
_
,
option
:=
range
options
{
if
option
<
minPageSize
||
option
>
maxPageSize
{
continue
}
if
_
,
ok
:=
seen
[
option
];
ok
{
continue
}
seen
[
option
]
=
struct
{}{}
normalizedOptions
=
append
(
normalizedOptions
,
option
)
}
sort
.
Ints
(
normalizedOptions
)
if
defaultPageSize
<
minPageSize
||
defaultPageSize
>
maxPageSize
{
defaultPageSize
=
fallbackPageSize
}
if
len
(
normalizedOptions
)
==
0
{
normalizedOptions
=
[]
int
{
10
,
20
,
50
}
}
return
defaultPageSize
,
normalizedOptions
}
// getStringOrDefault 获取字符串值或默认值
func
(
s
*
SettingService
)
getStringOrDefault
(
settings
map
[
string
]
string
,
key
,
defaultValue
string
)
string
{
if
value
,
ok
:=
settings
[
key
];
ok
&&
value
!=
""
{
...
...
@@ -1323,6 +1585,282 @@ func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settin
return
s
.
settingRepo
.
Set
(
ctx
,
SettingKeyOverloadCooldownSettings
,
string
(
data
))
}
// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。
//
// 优先级:
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
// - 否则回退到 config.yaml/env 的值
func
(
s
*
SettingService
)
GetOIDCConnectOAuthConfig
(
ctx
context
.
Context
)
(
config
.
OIDCConnectConfig
,
error
)
{
if
s
==
nil
||
s
.
cfg
==
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
ServiceUnavailable
(
"CONFIG_NOT_READY"
,
"config not loaded"
)
}
effective
:=
s
.
cfg
.
OIDC
keys
:=
[]
string
{
SettingKeyOIDCConnectEnabled
,
SettingKeyOIDCConnectProviderName
,
SettingKeyOIDCConnectClientID
,
SettingKeyOIDCConnectClientSecret
,
SettingKeyOIDCConnectIssuerURL
,
SettingKeyOIDCConnectDiscoveryURL
,
SettingKeyOIDCConnectAuthorizeURL
,
SettingKeyOIDCConnectTokenURL
,
SettingKeyOIDCConnectUserInfoURL
,
SettingKeyOIDCConnectJWKSURL
,
SettingKeyOIDCConnectScopes
,
SettingKeyOIDCConnectRedirectURL
,
SettingKeyOIDCConnectFrontendRedirectURL
,
SettingKeyOIDCConnectTokenAuthMethod
,
SettingKeyOIDCConnectUsePKCE
,
SettingKeyOIDCConnectValidateIDToken
,
SettingKeyOIDCConnectAllowedSigningAlgs
,
SettingKeyOIDCConnectClockSkewSeconds
,
SettingKeyOIDCConnectRequireEmailVerified
,
SettingKeyOIDCConnectUserInfoEmailPath
,
SettingKeyOIDCConnectUserInfoIDPath
,
SettingKeyOIDCConnectUserInfoUsernamePath
,
}
settings
,
err
:=
s
.
settingRepo
.
GetMultiple
(
ctx
,
keys
)
if
err
!=
nil
{
return
config
.
OIDCConnectConfig
{},
fmt
.
Errorf
(
"get oidc connect settings: %w"
,
err
)
}
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectEnabled
];
ok
{
effective
.
Enabled
=
raw
==
"true"
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectProviderName
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
ProviderName
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectClientID
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
ClientID
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectClientSecret
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
ClientSecret
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectIssuerURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
IssuerURL
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectDiscoveryURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
DiscoveryURL
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectAuthorizeURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
AuthorizeURL
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectTokenURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
TokenURL
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectUserInfoURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
UserInfoURL
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectJWKSURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
JWKSURL
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectScopes
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
Scopes
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectRedirectURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
RedirectURL
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectFrontendRedirectURL
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
FrontendRedirectURL
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectTokenAuthMethod
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
TokenAuthMethod
=
strings
.
ToLower
(
strings
.
TrimSpace
(
v
))
}
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectUsePKCE
];
ok
{
effective
.
UsePKCE
=
raw
==
"true"
}
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectValidateIDToken
];
ok
{
effective
.
ValidateIDToken
=
raw
==
"true"
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectAllowedSigningAlgs
];
ok
&&
strings
.
TrimSpace
(
v
)
!=
""
{
effective
.
AllowedSigningAlgs
=
strings
.
TrimSpace
(
v
)
}
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectClockSkewSeconds
];
ok
&&
strings
.
TrimSpace
(
raw
)
!=
""
{
if
parsed
,
parseErr
:=
strconv
.
Atoi
(
strings
.
TrimSpace
(
raw
));
parseErr
==
nil
{
effective
.
ClockSkewSeconds
=
parsed
}
}
if
raw
,
ok
:=
settings
[
SettingKeyOIDCConnectRequireEmailVerified
];
ok
{
effective
.
RequireEmailVerified
=
raw
==
"true"
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectUserInfoEmailPath
];
ok
{
effective
.
UserInfoEmailPath
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectUserInfoIDPath
];
ok
{
effective
.
UserInfoIDPath
=
strings
.
TrimSpace
(
v
)
}
if
v
,
ok
:=
settings
[
SettingKeyOIDCConnectUserInfoUsernamePath
];
ok
{
effective
.
UserInfoUsernamePath
=
strings
.
TrimSpace
(
v
)
}
if
!
effective
.
Enabled
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
NotFound
(
"OAUTH_DISABLED"
,
"oauth login is disabled"
)
}
if
strings
.
TrimSpace
(
effective
.
ProviderName
)
==
""
{
effective
.
ProviderName
=
"OIDC"
}
if
strings
.
TrimSpace
(
effective
.
ClientID
)
==
""
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth client id not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
IssuerURL
)
==
""
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth issuer url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
RedirectURL
)
==
""
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth redirect url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
FrontendRedirectURL
)
==
""
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth frontend redirect url not configured"
)
}
if
!
scopesContainOpenID
(
effective
.
Scopes
)
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth scopes must contain openid"
)
}
if
effective
.
ClockSkewSeconds
<
0
||
effective
.
ClockSkewSeconds
>
600
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth clock skew must be between 0 and 600"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
IssuerURL
);
err
!=
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth issuer url invalid"
)
}
discoveryURL
:=
strings
.
TrimSpace
(
effective
.
DiscoveryURL
)
if
discoveryURL
==
""
{
discoveryURL
=
oidcDefaultDiscoveryURL
(
effective
.
IssuerURL
)
effective
.
DiscoveryURL
=
discoveryURL
}
if
discoveryURL
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
discoveryURL
);
err
!=
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth discovery url invalid"
)
}
}
needsDiscovery
:=
strings
.
TrimSpace
(
effective
.
AuthorizeURL
)
==
""
||
strings
.
TrimSpace
(
effective
.
TokenURL
)
==
""
||
(
effective
.
ValidateIDToken
&&
strings
.
TrimSpace
(
effective
.
JWKSURL
)
==
""
)
if
needsDiscovery
&&
discoveryURL
!=
""
{
metadata
,
resolveErr
:=
oidcResolveProviderMetadata
(
ctx
,
discoveryURL
)
if
resolveErr
!=
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth discovery resolve failed"
)
.
WithCause
(
resolveErr
)
}
if
strings
.
TrimSpace
(
effective
.
AuthorizeURL
)
==
""
{
effective
.
AuthorizeURL
=
strings
.
TrimSpace
(
metadata
.
AuthorizationEndpoint
)
}
if
strings
.
TrimSpace
(
effective
.
TokenURL
)
==
""
{
effective
.
TokenURL
=
strings
.
TrimSpace
(
metadata
.
TokenEndpoint
)
}
if
strings
.
TrimSpace
(
effective
.
UserInfoURL
)
==
""
{
effective
.
UserInfoURL
=
strings
.
TrimSpace
(
metadata
.
UserInfoEndpoint
)
}
if
strings
.
TrimSpace
(
effective
.
JWKSURL
)
==
""
{
effective
.
JWKSURL
=
strings
.
TrimSpace
(
metadata
.
JWKSURI
)
}
}
if
strings
.
TrimSpace
(
effective
.
AuthorizeURL
)
==
""
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth authorize url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
TokenURL
)
==
""
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth token url not configured"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
AuthorizeURL
);
err
!=
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth authorize url invalid"
)
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
TokenURL
);
err
!=
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth token url invalid"
)
}
if
v
:=
strings
.
TrimSpace
(
effective
.
UserInfoURL
);
v
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
v
);
err
!=
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth userinfo url invalid"
)
}
}
if
effective
.
ValidateIDToken
{
if
strings
.
TrimSpace
(
effective
.
JWKSURL
)
==
""
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth jwks url not configured"
)
}
if
strings
.
TrimSpace
(
effective
.
AllowedSigningAlgs
)
==
""
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth signing algs not configured"
)
}
}
if
v
:=
strings
.
TrimSpace
(
effective
.
JWKSURL
);
v
!=
""
{
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
v
);
err
!=
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth jwks url invalid"
)
}
}
if
err
:=
config
.
ValidateAbsoluteHTTPURL
(
effective
.
RedirectURL
);
err
!=
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth redirect url invalid"
)
}
if
err
:=
config
.
ValidateFrontendRedirectURL
(
effective
.
FrontendRedirectURL
);
err
!=
nil
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth frontend redirect url invalid"
)
}
method
:=
strings
.
ToLower
(
strings
.
TrimSpace
(
effective
.
TokenAuthMethod
))
switch
method
{
case
""
,
"client_secret_post"
,
"client_secret_basic"
:
if
strings
.
TrimSpace
(
effective
.
ClientSecret
)
==
""
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth client secret not configured"
)
}
case
"none"
:
if
!
effective
.
UsePKCE
{
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth pkce must be enabled when token_auth_method=none"
)
}
default
:
return
config
.
OIDCConnectConfig
{},
infraerrors
.
InternalServer
(
"OAUTH_CONFIG_INVALID"
,
"oauth token_auth_method invalid"
)
}
return
effective
,
nil
}
func
scopesContainOpenID
(
scopes
string
)
bool
{
for
_
,
scope
:=
range
strings
.
Fields
(
strings
.
ToLower
(
strings
.
TrimSpace
(
scopes
)))
{
if
scope
==
"openid"
{
return
true
}
}
return
false
}
type
oidcProviderMetadata
struct
{
AuthorizationEndpoint
string
`json:"authorization_endpoint"`
TokenEndpoint
string
`json:"token_endpoint"`
UserInfoEndpoint
string
`json:"userinfo_endpoint"`
JWKSURI
string
`json:"jwks_uri"`
}
func
oidcDefaultDiscoveryURL
(
issuerURL
string
)
string
{
issuerURL
=
strings
.
TrimSpace
(
issuerURL
)
if
issuerURL
==
""
{
return
""
}
return
strings
.
TrimRight
(
issuerURL
,
"/"
)
+
"/.well-known/openid-configuration"
}
func
oidcResolveProviderMetadata
(
ctx
context
.
Context
,
discoveryURL
string
)
(
*
oidcProviderMetadata
,
error
)
{
discoveryURL
=
strings
.
TrimSpace
(
discoveryURL
)
if
discoveryURL
==
""
{
return
nil
,
fmt
.
Errorf
(
"discovery url is empty"
)
}
resp
,
err
:=
req
.
C
()
.
SetTimeout
(
15
*
time
.
Second
)
.
R
()
.
SetContext
(
ctx
)
.
SetHeader
(
"Accept"
,
"application/json"
)
.
Get
(
discoveryURL
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"request discovery document: %w"
,
err
)
}
if
!
resp
.
IsSuccessState
()
{
return
nil
,
fmt
.
Errorf
(
"discovery request failed: status=%d"
,
resp
.
StatusCode
)
}
metadata
:=
&
oidcProviderMetadata
{}
if
err
:=
json
.
Unmarshal
(
resp
.
Bytes
(),
metadata
);
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"parse discovery document: %w"
,
err
)
}
return
metadata
,
nil
}
// GetStreamTimeoutSettings 获取流超时处理配置
func
(
s
*
SettingService
)
GetStreamTimeoutSettings
(
ctx
context
.
Context
)
(
*
StreamTimeoutSettings
,
error
)
{
value
,
err
:=
s
.
settingRepo
.
GetValue
(
ctx
,
SettingKeyStreamTimeoutSettings
)
...
...
backend/internal/service/setting_service_oidc_config_test.go
0 → 100644
View file @
a04ae28a
//go:build unit
package
service
import
(
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
type
settingOIDCRepoStub
struct
{
values
map
[
string
]
string
}
func
(
s
*
settingOIDCRepoStub
)
Get
(
ctx
context
.
Context
,
key
string
)
(
*
Setting
,
error
)
{
panic
(
"unexpected Get call"
)
}
func
(
s
*
settingOIDCRepoStub
)
GetValue
(
ctx
context
.
Context
,
key
string
)
(
string
,
error
)
{
panic
(
"unexpected GetValue call"
)
}
func
(
s
*
settingOIDCRepoStub
)
Set
(
ctx
context
.
Context
,
key
,
value
string
)
error
{
panic
(
"unexpected Set call"
)
}
func
(
s
*
settingOIDCRepoStub
)
GetMultiple
(
ctx
context
.
Context
,
keys
[]
string
)
(
map
[
string
]
string
,
error
)
{
out
:=
make
(
map
[
string
]
string
,
len
(
keys
))
for
_
,
key
:=
range
keys
{
if
value
,
ok
:=
s
.
values
[
key
];
ok
{
out
[
key
]
=
value
}
}
return
out
,
nil
}
func
(
s
*
settingOIDCRepoStub
)
SetMultiple
(
ctx
context
.
Context
,
settings
map
[
string
]
string
)
error
{
panic
(
"unexpected SetMultiple call"
)
}
func
(
s
*
settingOIDCRepoStub
)
GetAll
(
ctx
context
.
Context
)
(
map
[
string
]
string
,
error
)
{
panic
(
"unexpected GetAll call"
)
}
func
(
s
*
settingOIDCRepoStub
)
Delete
(
ctx
context
.
Context
,
key
string
)
error
{
panic
(
"unexpected Delete call"
)
}
func
TestGetOIDCConnectOAuthConfig_ResolvesEndpointsFromIssuerDiscovery
(
t
*
testing
.
T
)
{
var
discoveryHits
int
var
baseURL
string
srv
:=
httptest
.
NewServer
(
http
.
HandlerFunc
(
func
(
w
http
.
ResponseWriter
,
r
*
http
.
Request
)
{
if
r
.
URL
.
Path
!=
"/issuer/.well-known/openid-configuration"
{
http
.
NotFound
(
w
,
r
)
return
}
discoveryHits
++
w
.
Header
()
.
Set
(
"Content-Type"
,
"application/json"
)
_
,
_
=
w
.
Write
([]
byte
(
fmt
.
Sprintf
(
`{
"authorization_endpoint":"%s/issuer/protocol/openid-connect/auth",
"token_endpoint":"%s/issuer/protocol/openid-connect/token",
"userinfo_endpoint":"%s/issuer/protocol/openid-connect/userinfo",
"jwks_uri":"%s/issuer/protocol/openid-connect/certs"
}`
,
baseURL
,
baseURL
,
baseURL
,
baseURL
)))
}))
defer
srv
.
Close
()
baseURL
=
srv
.
URL
cfg
:=
&
config
.
Config
{
OIDC
:
config
.
OIDCConnectConfig
{
Enabled
:
true
,
ProviderName
:
"OIDC"
,
ClientID
:
"oidc-client"
,
ClientSecret
:
"oidc-secret"
,
IssuerURL
:
srv
.
URL
+
"/issuer"
,
RedirectURL
:
"https://example.com/api/v1/auth/oauth/oidc/callback"
,
FrontendRedirectURL
:
"/auth/oidc/callback"
,
Scopes
:
"openid email profile"
,
TokenAuthMethod
:
"client_secret_post"
,
ValidateIDToken
:
true
,
AllowedSigningAlgs
:
"RS256"
,
ClockSkewSeconds
:
120
,
},
}
repo
:=
&
settingOIDCRepoStub
{
values
:
map
[
string
]
string
{}}
svc
:=
NewSettingService
(
repo
,
cfg
)
got
,
err
:=
svc
.
GetOIDCConnectOAuthConfig
(
context
.
Background
())
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
1
,
discoveryHits
)
require
.
Equal
(
t
,
srv
.
URL
+
"/issuer/.well-known/openid-configuration"
,
got
.
DiscoveryURL
)
require
.
Equal
(
t
,
srv
.
URL
+
"/issuer/protocol/openid-connect/auth"
,
got
.
AuthorizeURL
)
require
.
Equal
(
t
,
srv
.
URL
+
"/issuer/protocol/openid-connect/token"
,
got
.
TokenURL
)
require
.
Equal
(
t
,
srv
.
URL
+
"/issuer/protocol/openid-connect/userinfo"
,
got
.
UserInfoURL
)
require
.
Equal
(
t
,
srv
.
URL
+
"/issuer/protocol/openid-connect/certs"
,
got
.
JWKSURL
)
}
backend/internal/service/setting_service_public_test.go
View file @
a04ae28a
...
...
@@ -62,3 +62,18 @@ func TestSettingService_GetPublicSettings_ExposesRegistrationEmailSuffixWhitelis
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
[]
string
{
"@example.com"
,
"@foo.bar"
},
settings
.
RegistrationEmailSuffixWhitelist
)
}
func
TestSettingService_GetPublicSettings_ExposesTablePreferences
(
t
*
testing
.
T
)
{
repo
:=
&
settingPublicRepoStub
{
values
:
map
[
string
]
string
{
SettingKeyTableDefaultPageSize
:
"50"
,
SettingKeyTablePageSizeOptions
:
"[20,50,100]"
,
},
}
svc
:=
NewSettingService
(
repo
,
&
config
.
Config
{})
settings
,
err
:=
svc
.
GetPublicSettings
(
context
.
Background
())
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
50
,
settings
.
TableDefaultPageSize
)
require
.
Equal
(
t
,
[]
int
{
20
,
50
,
100
},
settings
.
TablePageSizeOptions
)
}
backend/internal/service/setting_service_update_test.go
View file @
a04ae28a
...
...
@@ -202,3 +202,24 @@ func TestParseDefaultSubscriptions_NormalizesValues(t *testing.T) {
{
GroupID
:
12
,
ValidityDays
:
MaxValidityDays
},
},
got
)
}
func
TestSettingService_UpdateSettings_TablePreferences
(
t
*
testing
.
T
)
{
repo
:=
&
settingUpdateRepoStub
{}
svc
:=
NewSettingService
(
repo
,
&
config
.
Config
{})
err
:=
svc
.
UpdateSettings
(
context
.
Background
(),
&
SystemSettings
{
TableDefaultPageSize
:
50
,
TablePageSizeOptions
:
[]
int
{
20
,
50
,
100
},
})
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"50"
,
repo
.
updates
[
SettingKeyTableDefaultPageSize
])
require
.
Equal
(
t
,
"[20,50,100]"
,
repo
.
updates
[
SettingKeyTablePageSizeOptions
])
err
=
svc
.
UpdateSettings
(
context
.
Background
(),
&
SystemSettings
{
TableDefaultPageSize
:
1000
,
TablePageSizeOptions
:
[]
int
{
20
,
100
},
})
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
"1000"
,
repo
.
updates
[
SettingKeyTableDefaultPageSize
])
require
.
Equal
(
t
,
"[20,100]"
,
repo
.
updates
[
SettingKeyTablePageSizeOptions
])
}
backend/internal/service/settings_view.go
View file @
a04ae28a
...
...
@@ -31,6 +31,31 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured
bool
LinuxDoConnectRedirectURL
string
// Generic OIDC OAuth 登录
OIDCConnectEnabled
bool
OIDCConnectProviderName
string
OIDCConnectClientID
string
OIDCConnectClientSecret
string
OIDCConnectClientSecretConfigured
bool
OIDCConnectIssuerURL
string
OIDCConnectDiscoveryURL
string
OIDCConnectAuthorizeURL
string
OIDCConnectTokenURL
string
OIDCConnectUserInfoURL
string
OIDCConnectJWKSURL
string
OIDCConnectScopes
string
OIDCConnectRedirectURL
string
OIDCConnectFrontendRedirectURL
string
OIDCConnectTokenAuthMethod
string
OIDCConnectUsePKCE
bool
OIDCConnectValidateIDToken
bool
OIDCConnectAllowedSigningAlgs
string
OIDCConnectClockSkewSeconds
int
OIDCConnectRequireEmailVerified
bool
OIDCConnectUserInfoEmailPath
string
OIDCConnectUserInfoIDPath
string
OIDCConnectUserInfoUsernamePath
string
SiteName
string
SiteLogo
string
SiteSubtitle
string
...
...
@@ -41,6 +66,8 @@ type SystemSettings struct {
HideCcsImportButton
bool
PurchaseSubscriptionEnabled
bool
PurchaseSubscriptionURL
string
TableDefaultPageSize
int
TablePageSizeOptions
[]
int
CustomMenuItems
string
// JSON array of custom menu items
CustomEndpoints
string
// JSON array of custom endpoints
...
...
@@ -107,11 +134,16 @@ type PublicSettings struct {
PurchaseSubscriptionEnabled
bool
PurchaseSubscriptionURL
string
TableDefaultPageSize
int
TablePageSizeOptions
[]
int
CustomMenuItems
string
// JSON array of custom menu items
CustomEndpoints
string
// JSON array of custom endpoints
LinuxDoOAuthEnabled
bool
BackendModeEnabled
bool
OIDCOAuthEnabled
bool
OIDCOAuthProviderName
string
PaymentEnabled
bool
Version
string
}
...
...
backend/internal/service/wire.go
View file @
a04ae28a
...
...
@@ -5,7 +5,9 @@ import (
"database/sql"
"time"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/google/wire"
"github.com/redis/go-redis/v9"
...
...
@@ -460,4 +462,20 @@ var ProviderSet = wire.NewSet(
NewGroupCapacityService
,
NewChannelService
,
NewModelPricingResolver
,
ProvidePaymentConfigService
,
NewPaymentService
,
ProvidePaymentOrderExpiryService
,
)
// ProvidePaymentConfigService wraps NewPaymentConfigService to accept the named
// payment.EncryptionKey type instead of raw []byte, avoiding Wire ambiguity.
func
ProvidePaymentConfigService
(
entClient
*
dbent
.
Client
,
settingRepo
SettingRepository
,
key
payment
.
EncryptionKey
)
*
PaymentConfigService
{
return
NewPaymentConfigService
(
entClient
,
settingRepo
,
[]
byte
(
key
))
}
// ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService.
func
ProvidePaymentOrderExpiryService
(
paymentSvc
*
PaymentService
)
*
PaymentOrderExpiryService
{
svc
:=
NewPaymentOrderExpiryService
(
paymentSvc
,
60
*
time
.
Second
)
svc
.
Start
()
return
svc
}
backend/migrations/091_add_group_messages_dispatch_model_config.sql
0 → 100644
View file @
a04ae28a
ALTER
TABLE
groups
ADD
COLUMN
IF
NOT
EXISTS
messages_dispatch_model_config
JSONB
NOT
NULL
DEFAULT
'{}'
::
jsonb
;
backend/migrations/092_payment_orders.sql
0 → 100644
View file @
a04ae28a
CREATE
TABLE
IF
NOT
EXISTS
payment_orders
(
id
BIGSERIAL
PRIMARY
KEY
,
user_id
BIGINT
NOT
NULL
,
user_email
VARCHAR
(
255
)
NOT
NULL
DEFAULT
''
,
user_name
VARCHAR
(
100
)
NOT
NULL
DEFAULT
''
,
user_notes
TEXT
,
amount
DECIMAL
(
20
,
2
)
NOT
NULL
,
pay_amount
DECIMAL
(
20
,
2
)
NOT
NULL
,
fee_rate
DECIMAL
(
10
,
4
)
NOT
NULL
DEFAULT
0
,
recharge_code
VARCHAR
(
64
)
NOT
NULL
DEFAULT
''
,
payment_type
VARCHAR
(
30
)
NOT
NULL
DEFAULT
''
,
payment_trade_no
VARCHAR
(
128
)
NOT
NULL
DEFAULT
''
,
pay_url
TEXT
,
qr_code
TEXT
,
qr_code_img
TEXT
,
order_type
VARCHAR
(
20
)
NOT
NULL
DEFAULT
'balance'
,
plan_id
BIGINT
,
subscription_group_id
BIGINT
,
subscription_days
INT
,
provider_instance_id
VARCHAR
(
64
),
status
VARCHAR
(
30
)
NOT
NULL
DEFAULT
'PENDING'
,
refund_amount
DECIMAL
(
20
,
2
)
NOT
NULL
DEFAULT
0
,
refund_reason
TEXT
,
refund_at
TIMESTAMPTZ
,
force_refund
BOOLEAN
NOT
NULL
DEFAULT
FALSE
,
refund_requested_at
TIMESTAMPTZ
,
refund_request_reason
TEXT
,
refund_requested_by
VARCHAR
(
20
),
expires_at
TIMESTAMPTZ
NOT
NULL
,
paid_at
TIMESTAMPTZ
,
completed_at
TIMESTAMPTZ
,
failed_at
TIMESTAMPTZ
,
failed_reason
TEXT
,
client_ip
VARCHAR
(
50
)
NOT
NULL
DEFAULT
''
,
src_host
VARCHAR
(
255
)
NOT
NULL
DEFAULT
''
,
src_url
TEXT
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
-- Indexes
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_user_id
ON
payment_orders
(
user_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_status
ON
payment_orders
(
status
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_expires_at
ON
payment_orders
(
expires_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_created_at
ON
payment_orders
(
created_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_paid_at
ON
payment_orders
(
paid_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_type_paid
ON
payment_orders
(
payment_type
,
paid_at
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_orders_order_type
ON
payment_orders
(
order_type
);
backend/migrations/093_payment_audit_logs.sql
0 → 100644
View file @
a04ae28a
CREATE
TABLE
IF
NOT
EXISTS
payment_audit_logs
(
id
BIGSERIAL
PRIMARY
KEY
,
order_id
VARCHAR
(
64
)
NOT
NULL
,
action
VARCHAR
(
50
)
NOT
NULL
,
detail
TEXT
NOT
NULL
DEFAULT
''
,
operator
VARCHAR
(
100
)
NOT
NULL
DEFAULT
'system'
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_audit_logs_order_id
ON
payment_audit_logs
(
order_id
);
backend/migrations/094_removed_payment_channels.sql
0 → 100644
View file @
a04ae28a
-- Migration 092: payment_channels table was removed before release.
-- This file is a no-op placeholder to maintain migration numbering continuity.
-- The payment system now uses the existing channels table (migration 081).
SELECT
1
;
backend/migrations/095_subscription_plans.sql
0 → 100644
View file @
a04ae28a
CREATE
TABLE
IF
NOT
EXISTS
subscription_plans
(
id
BIGSERIAL
PRIMARY
KEY
,
group_id
BIGINT
NOT
NULL
,
name
VARCHAR
(
100
)
NOT
NULL
,
description
TEXT
NOT
NULL
DEFAULT
''
,
price
DECIMAL
(
20
,
2
)
NOT
NULL
,
original_price
DECIMAL
(
20
,
2
),
validity_days
INT
NOT
NULL
DEFAULT
30
,
validity_unit
VARCHAR
(
10
)
NOT
NULL
DEFAULT
'day'
,
features
TEXT
NOT
NULL
DEFAULT
''
,
product_name
VARCHAR
(
100
)
NOT
NULL
DEFAULT
''
,
for_sale
BOOLEAN
NOT
NULL
DEFAULT
TRUE
,
sort_order
INT
NOT
NULL
DEFAULT
0
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_subscription_plans_group_id
ON
subscription_plans
(
group_id
);
CREATE
INDEX
IF
NOT
EXISTS
idx_subscription_plans_for_sale
ON
subscription_plans
(
for_sale
);
backend/migrations/096_payment_provider_instances.sql
0 → 100644
View file @
a04ae28a
CREATE
TABLE
IF
NOT
EXISTS
payment_provider_instances
(
id
BIGSERIAL
PRIMARY
KEY
,
provider_key
VARCHAR
(
30
)
NOT
NULL
,
name
VARCHAR
(
100
)
NOT
NULL
DEFAULT
''
,
config
TEXT
NOT
NULL
,
supported_types
VARCHAR
(
200
)
NOT
NULL
DEFAULT
''
,
enabled
BOOLEAN
NOT
NULL
DEFAULT
TRUE
,
sort_order
INT
NOT
NULL
DEFAULT
0
,
limits
TEXT
NOT
NULL
DEFAULT
''
,
refund_enabled
BOOLEAN
NOT
NULL
DEFAULT
FALSE
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
(),
updated_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_provider_instances_provider_key
ON
payment_provider_instances
(
provider_key
);
CREATE
INDEX
IF
NOT
EXISTS
idx_payment_provider_instances_enabled
ON
payment_provider_instances
(
enabled
);
backend/migrations/098_migrate_purchase_subscription_to_custom_menu.sql
0 → 100644
View file @
a04ae28a
-- 096_migrate_purchase_subscription_to_custom_menu.sql
--
-- Migrates the legacy purchase_subscription_url setting into custom_menu_items.
-- After migration, purchase_subscription_enabled is set to "false" and
-- purchase_subscription_url is cleared.
--
-- Idempotent: skips if custom_menu_items already contains
-- "migrated_purchase_subscription".
DO
$$
DECLARE
v_enabled
text
;
v_url
text
;
v_raw
text
;
v_items
jsonb
;
v_new_item
jsonb
;
BEGIN
-- Read legacy settings
SELECT
value
INTO
v_enabled
FROM
settings
WHERE
key
=
'purchase_subscription_enabled'
;
SELECT
value
INTO
v_url
FROM
settings
WHERE
key
=
'purchase_subscription_url'
;
-- Skip if not enabled or URL is empty
IF
COALESCE
(
v_enabled
,
''
)
<>
'true'
OR
COALESCE
(
TRIM
(
v_url
),
''
)
=
''
THEN
RETURN
;
END
IF
;
-- Read current custom_menu_items
SELECT
value
INTO
v_raw
FROM
settings
WHERE
key
=
'custom_menu_items'
;
IF
COALESCE
(
v_raw
,
''
)
=
''
OR
v_raw
=
'null'
THEN
v_items
:
=
'[]'
::
jsonb
;
ELSE
v_items
:
=
v_raw
::
jsonb
;
END
IF
;
-- Skip if already migrated (item with id "migrated_purchase_subscription" exists)
IF
EXISTS
(
SELECT
1
FROM
jsonb_array_elements
(
v_items
)
elem
WHERE
elem
->>
'id'
=
'migrated_purchase_subscription'
)
THEN
RETURN
;
END
IF
;
-- Build the new menu item
v_new_item
:
=
jsonb_build_object
(
'id'
,
'migrated_purchase_subscription'
,
'label'
,
'Purchase'
,
'icon_svg'
,
''
,
'url'
,
TRIM
(
v_url
),
'visibility'
,
'user'
,
'sort_order'
,
100
);
-- Append to array
v_items
:
=
v_items
||
jsonb_build_array
(
v_new_item
);
-- Upsert custom_menu_items
INSERT
INTO
settings
(
key
,
value
)
VALUES
(
'custom_menu_items'
,
v_items
::
text
)
ON
CONFLICT
(
key
)
DO
UPDATE
SET
value
=
EXCLUDED
.
value
;
-- Clear legacy settings
UPDATE
settings
SET
value
=
'false'
WHERE
key
=
'purchase_subscription_enabled'
;
UPDATE
settings
SET
value
=
''
WHERE
key
=
'purchase_subscription_url'
;
RAISE
NOTICE
'[migration-096] Migrated purchase_subscription_url (%) to custom_menu_items'
,
v_url
;
END
$$
;
backend/migrations/099_fix_migrated_purchase_menu_label_icon.sql
0 → 100644
View file @
a04ae28a
-- 097_fix_migrated_purchase_menu_label_icon.sql
--
-- Fixes the custom menu item created by migration 096: updates the label
-- from hardcoded English "Purchase" to "充值/订阅", and sets the icon_svg
-- to a credit-card SVG matching the sidebar CreditCardIcon.
--
-- Idempotent: only modifies items where id = 'migrated_purchase_subscription'.
DO
$$
DECLARE
v_raw
text
;
v_items
jsonb
;
v_idx
int
;
v_icon
text
;
v_elem
jsonb
;
v_i
int
:
=
0
;
BEGIN
SELECT
value
INTO
v_raw
FROM
settings
WHERE
key
=
'custom_menu_items'
;
IF
COALESCE
(
v_raw
,
''
)
=
''
OR
v_raw
=
'null'
THEN
RETURN
;
END
IF
;
v_items
:
=
v_raw
::
jsonb
;
-- Find the index of the migrated item by iterating the array
v_idx
:
=
NULL
;
FOR
v_elem
IN
SELECT
jsonb_array_elements
(
v_items
)
LOOP
IF
v_elem
->>
'id'
=
'migrated_purchase_subscription'
THEN
v_idx
:
=
v_i
;
EXIT
;
END
IF
;
v_i
:
=
v_i
+
1
;
END
LOOP
;
IF
v_idx
IS
NULL
THEN
RETURN
;
-- item not found, nothing to fix
END
IF
;
-- Credit card SVG (Heroicons outline, matches CreditCardIcon in AppSidebar)
v_icon
:
=
'<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"/></svg>'
;
-- Update label and icon_svg
v_items
:
=
jsonb_set
(
v_items
,
ARRAY
[
v_idx
::
text
,
'label'
],
'"充值/订阅"'
::
jsonb
);
v_items
:
=
jsonb_set
(
v_items
,
ARRAY
[
v_idx
::
text
,
'icon_svg'
],
to_jsonb
(
v_icon
));
UPDATE
settings
SET
value
=
v_items
::
text
WHERE
key
=
'custom_menu_items'
;
RAISE
NOTICE
'[migration-097] Fixed migrated_purchase_subscription: label=充值/订阅, icon=CreditCard SVG'
;
END
$$
;
backend/migrations/100_remove_easypay_from_enabled_payment_types.sql
0 → 100644
View file @
a04ae28a
-- 098_remove_easypay_from_enabled_payment_types.sql
--
-- Removes "easypay" from ENABLED_PAYMENT_TYPES setting.
-- "easypay" is a provider key, not a payment type. Valid payment types
-- are: alipay, wxpay, alipay_direct, wxpay_direct, stripe.
--
-- Idempotent: safe to run multiple times.
UPDATE
settings
SET
value
=
array_to_string
(
array_remove
(
string_to_array
(
value
,
','
),
'easypay'
),
','
)
WHERE
key
=
'ENABLED_PAYMENT_TYPES'
AND
value
LIKE
'%easypay%'
;
backend/migrations/101_add_payment_mode.sql
0 → 100644
View file @
a04ae28a
-- Add payment_mode field to payment_provider_instances
-- Values: 'redirect' (hosted page redirect), 'api' (API call for QR/payurl), '' (default/N/A)
ALTER
TABLE
payment_provider_instances
ADD
COLUMN
IF
NOT
EXISTS
payment_mode
VARCHAR
(
20
)
NOT
NULL
DEFAULT
''
;
-- Migrate existing data: easypay instances with 'easypay' in supported_types → redirect mode
-- Remove 'easypay' from supported_types and set payment_mode = 'redirect'
UPDATE
payment_provider_instances
SET
payment_mode
=
'redirect'
,
supported_types
=
TRIM
(
BOTH
','
FROM
REPLACE
(
REPLACE
(
REPLACE
(
supported_types
,
'easypay,'
,
''
),
',easypay'
,
''
),
'easypay'
,
''
))
WHERE
provider_key
=
'easypay'
AND
supported_types
LIKE
'%easypay%'
;
-- EasyPay instances without 'easypay' in supported_types → api mode
UPDATE
payment_provider_instances
SET
payment_mode
=
'api'
WHERE
provider_key
=
'easypay'
AND
payment_mode
=
''
;
backend/migrations/102_add_out_trade_no_to_payment_orders.sql
0 → 100644
View file @
a04ae28a
-- 100_add_out_trade_no_to_payment_orders.sql
-- Adds out_trade_no column for external order ID used with payment providers.
-- Allows webhook handlers to look up orders by external ID instead of embedding DB ID.
ALTER
TABLE
payment_orders
ADD
COLUMN
IF
NOT
EXISTS
out_trade_no
VARCHAR
(
64
)
NOT
NULL
DEFAULT
''
;
CREATE
INDEX
IF
NOT
EXISTS
paymentorder_out_trade_no
ON
payment_orders
(
out_trade_no
);
deploy/codex-instructions.md.tmpl
0 → 100644
View file @
a04ae28a
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
{{ if .ExistingInstructions }}
{{ .ExistingInstructions }}
{{ end }}
deploy/config.example.yaml
View file @
a04ae28a
...
...
@@ -202,6 +202,32 @@ gateway:
#
# 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。
force_codex_cli
:
false
# Optional: template file used to build the final top-level Codex `instructions`.
# 可选:用于构建最终 Codex 顶层 `instructions` 的模板文件路径。
#
# This is applied on the `/v1/messages -> Responses/Codex` conversion path,
# after Claude `system` has already been normalized into Codex `instructions`.
# 该模板作用于 `/v1/messages -> Responses/Codex` 转换链路,且发生在 Claude `system`
# 已经被归一化为 Codex `instructions` 之后。
#
# The template can reference:
# 模板可引用:
# - {{ .ExistingInstructions }} : converted client instructions/system
# - {{ .OriginalModel }} : original requested model
# - {{ .NormalizedModel }} : normalized routing model
# - {{ .BillingModel }} : billing model
# - {{ .UpstreamModel }} : final upstream model
#
# If you want to preserve client system prompts, keep {{ .ExistingInstructions }}
# somewhere in the template. If omitted, the template output fully replaces it.
# 如需保留客户端 system 提示词,请在模板中显式包含 {{ .ExistingInstructions }}。
# 若省略,则模板输出会完全覆盖它。
#
# Docker users can mount a host file to /app/data/codex-instructions.md.tmpl
# and point this field there.
# Docker 用户可将宿主机文件挂载到 /app/data/codex-instructions.md.tmpl,
# 然后把本字段指向该路径。
forced_codex_instructions_template_file
:
"
"
# OpenAI 透传模式是否放行客户端超时头(如 x-stainless-timeout)
# 默认 false:过滤超时头,降低上游提前断流风险。
openai_passthrough_allow_timeout_headers
:
false
...
...
@@ -820,6 +846,46 @@ linuxdo_connect:
userinfo_id_path
:
"
"
userinfo_username_path
:
"
"
# =============================================================================
# Generic OIDC OAuth Login (SSO)
# 通用 OIDC OAuth 登录(用于 Sub2API 用户登录)
# =============================================================================
oidc_connect
:
enabled
:
false
provider_name
:
"
OIDC"
client_id
:
"
"
client_secret
:
"
"
# 例如: "https://keycloak.example.com/realms/myrealm"
issuer_url
:
"
"
# 可选: OIDC Discovery URL。为空时可手动填写 authorize/token/userinfo/jwks
discovery_url
:
"
"
authorize_url
:
"
"
token_url
:
"
"
# 可选(仅补充 email/username,不用于 sub 可信绑定)
userinfo_url
:
"
"
# validate_id_token=true 时必填
jwks_url
:
"
"
scopes
:
"
openid
email
profile"
# 示例: "https://your-domain.com/api/v1/auth/oauth/oidc/callback"
redirect_url
:
"
"
# 安全提示:
# - 建议使用同源相对路径(以 / 开头),避免把 token 重定向到意外的第三方域名
# - 该地址不应包含 #fragment(本实现使用 URL fragment 传递 access_token)
frontend_redirect_url
:
"
/auth/oidc/callback"
token_auth_method
:
"
client_secret_post"
# client_secret_post | client_secret_basic | none
# 注意:当 token_auth_method=none(public client)时,必须启用 PKCE
use_pkce
:
false
# 开启后强制校验 id_token 的签名和 claims(推荐)
validate_id_token
:
true
allowed_signing_algs
:
"
RS256,ES256,PS256"
# 允许的时钟偏移(秒)
clock_skew_seconds
:
120
# 若 Provider 返回 email_verified=false,是否拒绝登录
require_email_verified
:
false
userinfo_email_path
:
"
"
userinfo_id_path
:
"
"
userinfo_username_path
:
"
"
# =============================================================================
# Default Settings
# 默认设置
...
...
deploy/docker-compose.yml
View file @
a04ae28a
...
...
@@ -31,6 +31,10 @@ services:
# Optional: Mount custom config.yaml (uncomment and create the file first)
# Copy config.example.yaml to config.yaml, modify it, then uncomment:
# - ./config.yaml:/app/data/config.yaml
# Optional: Mount a custom Codex instructions template file, then point
# gateway.forced_codex_instructions_template_file at /app/data/codex-instructions.md.tmpl
# in config.yaml.
# - ./codex-instructions.md.tmpl:/app/data/codex-instructions.md.tmpl:ro
environment
:
# =======================================================================
# Auto Setup (REQUIRED for Docker deployment)
...
...
@@ -146,7 +150,17 @@ services:
networks
:
-
sub2api-network
healthcheck
:
test
:
[
"
CMD"
,
"
wget"
,
"
-q"
,
"
-T"
,
"
5"
,
"
-O"
,
"
/dev/null"
,
"
http://localhost:8080/health"
]
test
:
[
"
CMD"
,
"
wget"
,
"
-q"
,
"
-T"
,
"
5"
,
"
-O"
,
"
/dev/null"
,
"
http://localhost:8080/health"
,
]
interval
:
30s
timeout
:
10s
retries
:
3
...
...
@@ -177,11 +191,17 @@ services:
networks
:
-
sub2api-network
healthcheck
:
test
:
[
"
CMD-SHELL"
,
"
pg_isready
-U
${POSTGRES_USER:-sub2api}
-d
${POSTGRES_DB:-sub2api}"
]
test
:
[
"
CMD-SHELL"
,
"
pg_isready
-U
${POSTGRES_USER:-sub2api}
-d
${POSTGRES_DB:-sub2api}"
,
]
interval
:
10s
timeout
:
5s
retries
:
5
start_period
:
10s
ports
:
-
5432:5432
# 注意:不暴露端口到宿主机,应用通过内部网络连接
# 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"]
...
...
@@ -217,7 +237,8 @@ services:
timeout
:
5s
retries
:
5
start_period
:
5s
ports
:
-
6379:6379
# =============================================================================
# Volumes
# =============================================================================
...
...
Prev
1
…
7
8
9
10
11
12
13
14
15
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