Commit ee3f158f authored by IanShaw027's avatar IanShaw027
Browse files

fix(settings): restore wechat and payment config persistence

parent d08757ce
...@@ -122,6 +122,13 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ...@@ -122,6 +122,13 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
LinuxDoConnectClientID: settings.LinuxDoConnectClientID, LinuxDoConnectClientID: settings.LinuxDoConnectClientID,
LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured,
LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL, LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL,
WeChatConnectEnabled: settings.WeChatConnectEnabled,
WeChatConnectAppID: settings.WeChatConnectAppID,
WeChatConnectAppSecretConfigured: settings.WeChatConnectAppSecretConfigured,
WeChatConnectMode: settings.WeChatConnectMode,
WeChatConnectScopes: settings.WeChatConnectScopes,
WeChatConnectRedirectURL: settings.WeChatConnectRedirectURL,
WeChatConnectFrontendRedirectURL: settings.WeChatConnectFrontendRedirectURL,
OIDCConnectEnabled: settings.OIDCConnectEnabled, OIDCConnectEnabled: settings.OIDCConnectEnabled,
OIDCConnectProviderName: settings.OIDCConnectProviderName, OIDCConnectProviderName: settings.OIDCConnectProviderName,
OIDCConnectClientID: settings.OIDCConnectClientID, OIDCConnectClientID: settings.OIDCConnectClientID,
...@@ -246,6 +253,15 @@ type UpdateSettingsRequest struct { ...@@ -246,6 +253,15 @@ type UpdateSettingsRequest struct {
LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"` LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"`
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
// WeChat Connect OAuth 登录
WeChatConnectEnabled bool `json:"wechat_connect_enabled"`
WeChatConnectAppID string `json:"wechat_connect_app_id"`
WeChatConnectAppSecret string `json:"wechat_connect_app_secret"`
WeChatConnectMode string `json:"wechat_connect_mode"`
WeChatConnectScopes string `json:"wechat_connect_scopes"`
WeChatConnectRedirectURL string `json:"wechat_connect_redirect_url"`
WeChatConnectFrontendRedirectURL string `json:"wechat_connect_frontend_redirect_url"`
// Generic OIDC OAuth 登录 // Generic OIDC OAuth 登录
OIDCConnectEnabled bool `json:"oidc_connect_enabled"` OIDCConnectEnabled bool `json:"oidc_connect_enabled"`
OIDCConnectProviderName string `json:"oidc_connect_provider_name"` OIDCConnectProviderName string `json:"oidc_connect_provider_name"`
...@@ -509,6 +525,54 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -509,6 +525,54 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
} }
} }
if req.WeChatConnectEnabled {
req.WeChatConnectAppID = strings.TrimSpace(req.WeChatConnectAppID)
req.WeChatConnectAppSecret = strings.TrimSpace(req.WeChatConnectAppSecret)
req.WeChatConnectMode = strings.ToLower(strings.TrimSpace(req.WeChatConnectMode))
req.WeChatConnectScopes = strings.TrimSpace(req.WeChatConnectScopes)
req.WeChatConnectRedirectURL = strings.TrimSpace(req.WeChatConnectRedirectURL)
req.WeChatConnectFrontendRedirectURL = strings.TrimSpace(req.WeChatConnectFrontendRedirectURL)
if req.WeChatConnectAppID == "" {
response.BadRequest(c, "WeChat App ID is required when enabled")
return
}
if req.WeChatConnectAppSecret == "" {
if previousSettings.WeChatConnectAppSecret == "" {
response.BadRequest(c, "WeChat App Secret is required when enabled")
return
}
req.WeChatConnectAppSecret = previousSettings.WeChatConnectAppSecret
}
if req.WeChatConnectMode == "" {
req.WeChatConnectMode = "open"
}
switch req.WeChatConnectMode {
case "open", "mp":
default:
response.BadRequest(c, "WeChat mode must be open or mp")
return
}
if req.WeChatConnectScopes == "" {
req.WeChatConnectScopes = service.DefaultWeChatConnectScopesForMode(req.WeChatConnectMode)
}
if req.WeChatConnectRedirectURL == "" {
response.BadRequest(c, "WeChat Redirect URL is required when enabled")
return
}
if err := config.ValidateAbsoluteHTTPURL(req.WeChatConnectRedirectURL); err != nil {
response.BadRequest(c, "WeChat Redirect URL must be an absolute http(s) URL")
return
}
if req.WeChatConnectFrontendRedirectURL == "" {
req.WeChatConnectFrontendRedirectURL = "/auth/wechat/callback"
}
if err := config.ValidateFrontendRedirectURL(req.WeChatConnectFrontendRedirectURL); err != nil {
response.BadRequest(c, "WeChat Frontend Redirect URL is invalid")
return
}
}
// Generic OIDC 参数验证 // Generic OIDC 参数验证
if req.OIDCConnectEnabled { if req.OIDCConnectEnabled {
req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName) req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName)
...@@ -857,6 +921,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -857,6 +921,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
LinuxDoConnectClientID: req.LinuxDoConnectClientID, LinuxDoConnectClientID: req.LinuxDoConnectClientID,
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
WeChatConnectEnabled: req.WeChatConnectEnabled,
WeChatConnectAppID: req.WeChatConnectAppID,
WeChatConnectAppSecret: req.WeChatConnectAppSecret,
WeChatConnectMode: req.WeChatConnectMode,
WeChatConnectScopes: req.WeChatConnectScopes,
WeChatConnectRedirectURL: req.WeChatConnectRedirectURL,
WeChatConnectFrontendRedirectURL: req.WeChatConnectFrontendRedirectURL,
OIDCConnectEnabled: req.OIDCConnectEnabled, OIDCConnectEnabled: req.OIDCConnectEnabled,
OIDCConnectProviderName: req.OIDCConnectProviderName, OIDCConnectProviderName: req.OIDCConnectProviderName,
OIDCConnectClientID: req.OIDCConnectClientID, OIDCConnectClientID: req.OIDCConnectClientID,
...@@ -1136,6 +1207,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ...@@ -1136,6 +1207,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL, LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL,
WeChatConnectEnabled: updatedSettings.WeChatConnectEnabled,
WeChatConnectAppID: updatedSettings.WeChatConnectAppID,
WeChatConnectAppSecretConfigured: updatedSettings.WeChatConnectAppSecretConfigured,
WeChatConnectMode: updatedSettings.WeChatConnectMode,
WeChatConnectScopes: updatedSettings.WeChatConnectScopes,
WeChatConnectRedirectURL: updatedSettings.WeChatConnectRedirectURL,
WeChatConnectFrontendRedirectURL: updatedSettings.WeChatConnectFrontendRedirectURL,
OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled, OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled,
OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName, OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName,
OIDCConnectClientID: updatedSettings.OIDCConnectClientID, OIDCConnectClientID: updatedSettings.OIDCConnectClientID,
...@@ -1329,6 +1407,27 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, ...@@ -1329,6 +1407,27 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL { if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL {
changed = append(changed, "linuxdo_connect_redirect_url") changed = append(changed, "linuxdo_connect_redirect_url")
} }
if before.WeChatConnectEnabled != after.WeChatConnectEnabled {
changed = append(changed, "wechat_connect_enabled")
}
if before.WeChatConnectAppID != after.WeChatConnectAppID {
changed = append(changed, "wechat_connect_app_id")
}
if req.WeChatConnectAppSecret != "" {
changed = append(changed, "wechat_connect_app_secret")
}
if before.WeChatConnectMode != after.WeChatConnectMode {
changed = append(changed, "wechat_connect_mode")
}
if before.WeChatConnectScopes != after.WeChatConnectScopes {
changed = append(changed, "wechat_connect_scopes")
}
if before.WeChatConnectRedirectURL != after.WeChatConnectRedirectURL {
changed = append(changed, "wechat_connect_redirect_url")
}
if before.WeChatConnectFrontendRedirectURL != after.WeChatConnectFrontendRedirectURL {
changed = append(changed, "wechat_connect_frontend_redirect_url")
}
if before.OIDCConnectEnabled != after.OIDCConnectEnabled { if before.OIDCConnectEnabled != after.OIDCConnectEnabled {
changed = append(changed, "oidc_connect_enabled") changed = append(changed, "oidc_connect_enabled")
} }
......
...@@ -8,7 +8,6 @@ import ( ...@@ -8,7 +8,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
...@@ -149,7 +148,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) { ...@@ -149,7 +148,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
// WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid, // WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid,
// and stores the result in the unified pending-auth flow. // and stores the result in the unified pending-auth flow.
func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) { func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) {
frontendCallback := wechatOAuthFrontendCallback() frontendCallback := h.wechatOAuthFrontendCallback(c.Request.Context())
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" { if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description")) redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
...@@ -859,6 +858,10 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, ...@@ -859,6 +858,10 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string,
return wechatOAuthConfig{}, err return wechatOAuthConfig{}, err
} }
if h == nil || h.settingSvc == nil {
return wechatOAuthConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "wechat oauth settings service not ready")
}
apiBaseURL := "" apiBaseURL := ""
if h != nil && h.settingSvc != nil { if h != nil && h.settingSvc != nil {
settings, err := h.settingSvc.GetAllSettings(ctx) settings, err := h.settingSvc.GetAllSettings(ctx)
...@@ -867,27 +870,28 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, ...@@ -867,27 +870,28 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string,
} }
} }
effective, err := h.settingSvc.GetWeChatConnectOAuthConfig(ctx)
if err != nil {
return wechatOAuthConfig{}, err
}
if effective.Mode != mode {
return wechatOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
}
cfg := wechatOAuthConfig{ cfg := wechatOAuthConfig{
mode: mode, mode: mode,
redirectURI: resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback"), appID: strings.TrimSpace(effective.AppID),
frontendCallback: wechatOAuthFrontendCallback(), appSecret: strings.TrimSpace(effective.AppSecret),
redirectURI: firstNonEmpty(strings.TrimSpace(effective.RedirectURL), resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback")),
frontendCallback: firstNonEmpty(strings.TrimSpace(effective.FrontendRedirectURL), wechatOAuthDefaultFrontendCB),
scope: firstNonEmpty(strings.TrimSpace(effective.Scopes), service.DefaultWeChatConnectScopesForMode(mode)),
} }
switch mode { switch mode {
case "mp": case "mp":
cfg.appID = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID"))
cfg.appSecret = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET"))
cfg.authorizeURL = "https://open.weixin.qq.com/connect/oauth2/authorize" cfg.authorizeURL = "https://open.weixin.qq.com/connect/oauth2/authorize"
cfg.scope = "snsapi_userinfo"
default: default:
cfg.appID = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_ID"))
cfg.appSecret = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_SECRET"))
cfg.authorizeURL = "https://open.weixin.qq.com/connect/qrconnect" cfg.authorizeURL = "https://open.weixin.qq.com/connect/qrconnect"
cfg.scope = "snsapi_login"
}
if cfg.appID == "" || cfg.appSecret == "" {
return wechatOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
} }
if strings.TrimSpace(cfg.redirectURI) == "" { if strings.TrimSpace(cfg.redirectURI) == "" {
return wechatOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url not configured") return wechatOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url not configured")
...@@ -896,8 +900,14 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, ...@@ -896,8 +900,14 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string,
return cfg, nil return cfg, nil
} }
func wechatOAuthFrontendCallback() string { func (h *AuthHandler) wechatOAuthFrontendCallback(ctx context.Context) string {
return firstNonEmpty(strings.TrimSpace(os.Getenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL")), wechatOAuthDefaultFrontendCB) if h != nil && h.settingSvc != nil {
cfg, err := h.settingSvc.GetWeChatConnectOAuthConfig(ctx)
if err == nil && strings.TrimSpace(cfg.FrontendRedirectURL) != "" {
return strings.TrimSpace(cfg.FrontendRedirectURL)
}
}
return wechatOAuthDefaultFrontendCB
} }
func resolveWeChatOAuthMode(rawMode string, c *gin.Context) (string, error) { func resolveWeChatOAuthMode(rawMode string, c *gin.Context) (string, error) {
......
...@@ -33,16 +33,22 @@ import ( ...@@ -33,16 +33,22 @@ import (
) )
func TestWeChatOAuthStartRedirectsAndSetsPendingCookies(t *testing.T) { func TestWeChatOAuthStartRedirectsAndSetsPendingCookies(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, map[string]string{
service.SettingKeyWeChatConnectEnabled: "true",
service.SettingKeyWeChatConnectAppID: "wx-open-app",
service.SettingKeyWeChatConnectAppSecret: "wx-open-secret",
service.SettingKeyWeChatConnectMode: "open",
service.SettingKeyWeChatConnectScopes: "snsapi_login",
service.SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
service.SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
})
defer client.Close()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder) c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/start?mode=open&redirect=/billing", nil) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/start?mode=open&redirect=/billing", nil)
c.Request.Host = "api.example.com" c.Request.Host = "api.example.com"
handler := &AuthHandler{}
handler.WeChatOAuthStart(c) handler.WeChatOAuthStart(c)
require.Equal(t, http.StatusFound, recorder.Code) require.Equal(t, http.StatusFound, recorder.Code)
...@@ -60,10 +66,6 @@ func TestWeChatOAuthStartRedirectsAndSetsPendingCookies(t *testing.T) { ...@@ -60,10 +66,6 @@ func TestWeChatOAuthStartRedirectsAndSetsPendingCookies(t *testing.T) {
} }
func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(t *testing.T) { func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
originalUserInfoURL := wechatOAuthUserInfoURL originalUserInfoURL := wechatOAuthUserInfoURL
t.Cleanup(func() { t.Cleanup(func() {
...@@ -124,10 +126,6 @@ func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(t *testing.T) { ...@@ -124,10 +126,6 @@ func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(t *testing.T) {
} }
func TestWeChatOAuthCallbackRejectsMissingUnionID(t *testing.T) { func TestWeChatOAuthCallbackRejectsMissingUnionID(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "https://app.example.com/auth/wechat/callback")
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
originalUserInfoURL := wechatOAuthUserInfoURL originalUserInfoURL := wechatOAuthUserInfoURL
t.Cleanup(func() { t.Cleanup(func() {
...@@ -151,7 +149,7 @@ func TestWeChatOAuthCallbackRejectsMissingUnionID(t *testing.T) { ...@@ -151,7 +149,7 @@ func TestWeChatOAuthCallbackRejectsMissingUnionID(t *testing.T) {
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token" wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
wechatOAuthUserInfoURL = upstream.URL + "/sns/userinfo" wechatOAuthUserInfoURL = upstream.URL + "/sns/userinfo"
handler, client := newWeChatOAuthTestHandler(t, false) handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, wechatOAuthTestSettings("open", "wx-open-app", "wx-open-secret", "https://app.example.com/auth/wechat/callback"))
defer client.Close() defer client.Close()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
...@@ -177,9 +175,6 @@ func TestWeChatOAuthCallbackRejectsMissingUnionID(t *testing.T) { ...@@ -177,9 +175,6 @@ func TestWeChatOAuthCallbackRejectsMissingUnionID(t *testing.T) {
} }
func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T) { func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T) {
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx-mp-app")
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wx-mp-secret")
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
t.Cleanup(func() { t.Cleanup(func() {
wechatOAuthAccessTokenURL = originalAccessTokenURL wechatOAuthAccessTokenURL = originalAccessTokenURL
...@@ -196,7 +191,7 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T) ...@@ -196,7 +191,7 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T)
defer upstream.Close() defer upstream.Close()
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token" wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
handler, client := newWeChatOAuthTestHandler(t, false) handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, wechatOAuthTestSettings("mp", "wx-mp-app", "wx-mp-secret", "/auth/wechat/callback"))
defer client.Close() defer client.Close()
handler.cfg.Totp.EncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" handler.cfg.Totp.EncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
...@@ -240,7 +235,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test ...@@ -240,7 +235,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test
testCases := []struct { testCases := []struct {
name string name string
mode string mode string
appIDEnv string
appID string appID string
appSecret string appSecret string
openID string openID string
...@@ -248,7 +242,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test ...@@ -248,7 +242,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test
{ {
name: "open", name: "open",
mode: "open", mode: "open",
appIDEnv: "WECHAT_OAUTH_OPEN_APP_ID",
appID: "wx-open-app", appID: "wx-open-app",
appSecret: "wx-open-secret", appSecret: "wx-open-secret",
openID: "openid-open-123", openID: "openid-open-123",
...@@ -256,7 +249,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test ...@@ -256,7 +249,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test
{ {
name: "mp", name: "mp",
mode: "mp", mode: "mp",
appIDEnv: "WECHAT_OAUTH_MP_APP_ID",
appID: "wx-mp-app", appID: "wx-mp-app",
appSecret: "wx-mp-secret", appSecret: "wx-mp-secret",
openID: "openid-mp-123", openID: "openid-mp-123",
...@@ -265,15 +257,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test ...@@ -265,15 +257,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Setenv(tc.appIDEnv, tc.appID)
switch tc.mode {
case "open":
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", tc.appSecret)
case "mp":
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", tc.appSecret)
}
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
originalUserInfoURL := wechatOAuthUserInfoURL originalUserInfoURL := wechatOAuthUserInfoURL
t.Cleanup(func() { t.Cleanup(func() {
...@@ -297,7 +280,7 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test ...@@ -297,7 +280,7 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test
wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token" wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token"
wechatOAuthUserInfoURL = upstream.URL + "/sns/userinfo" wechatOAuthUserInfoURL = upstream.URL + "/sns/userinfo"
handler, client := newWeChatOAuthTestHandler(t, false) handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, wechatOAuthTestSettings(tc.mode, tc.appID, tc.appSecret, "/auth/wechat/callback"))
defer client.Close() defer client.Close()
currentUser, err := client.User.Create(). currentUser, err := client.User.Create().
...@@ -354,10 +337,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test ...@@ -354,10 +337,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test
} }
func TestWeChatOAuthCallbackBindRejectsCanonicalOwnershipConflict(t *testing.T) { func TestWeChatOAuthCallbackBindRejectsCanonicalOwnershipConflict(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
originalUserInfoURL := wechatOAuthUserInfoURL originalUserInfoURL := wechatOAuthUserInfoURL
t.Cleanup(func() { t.Cleanup(func() {
...@@ -436,10 +415,6 @@ func TestWeChatOAuthCallbackBindRejectsCanonicalOwnershipConflict(t *testing.T) ...@@ -436,10 +415,6 @@ func TestWeChatOAuthCallbackBindRejectsCanonicalOwnershipConflict(t *testing.T)
} }
func TestWeChatOAuthCallbackBindRejectsChannelOwnershipConflict(t *testing.T) { func TestWeChatOAuthCallbackBindRejectsChannelOwnershipConflict(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
originalUserInfoURL := wechatOAuthUserInfoURL originalUserInfoURL := wechatOAuthUserInfoURL
t.Cleanup(func() { t.Cleanup(func() {
...@@ -529,10 +504,6 @@ func TestWeChatOAuthCallbackBindRejectsChannelOwnershipConflict(t *testing.T) { ...@@ -529,10 +504,6 @@ func TestWeChatOAuthCallbackBindRejectsChannelOwnershipConflict(t *testing.T) {
} }
func TestWeChatOAuthCallbackBindRejectsLegacyProviderKeyOwnershipConflict(t *testing.T) { func TestWeChatOAuthCallbackBindRejectsLegacyProviderKeyOwnershipConflict(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
originalUserInfoURL := wechatOAuthUserInfoURL originalUserInfoURL := wechatOAuthUserInfoURL
t.Cleanup(func() { t.Cleanup(func() {
...@@ -611,10 +582,6 @@ func TestWeChatOAuthCallbackBindRejectsLegacyProviderKeyOwnershipConflict(t *tes ...@@ -611,10 +582,6 @@ func TestWeChatOAuthCallbackBindRejectsLegacyProviderKeyOwnershipConflict(t *tes
} }
func TestCompleteWeChatOAuthRegistrationAfterInvitationPendingSession(t *testing.T) { func TestCompleteWeChatOAuthRegistrationAfterInvitationPendingSession(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
originalUserInfoURL := wechatOAuthUserInfoURL originalUserInfoURL := wechatOAuthUserInfoURL
t.Cleanup(func() { t.Cleanup(func() {
...@@ -737,10 +704,6 @@ func TestCompleteWeChatOAuthRegistrationAfterInvitationPendingSession(t *testing ...@@ -737,10 +704,6 @@ func TestCompleteWeChatOAuthRegistrationAfterInvitationPendingSession(t *testing
} }
func TestWeChatOAuthCallbackRepairsLegacyOpenIDOnlyIdentity(t *testing.T) { func TestWeChatOAuthCallbackRepairsLegacyOpenIDOnlyIdentity(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
originalUserInfoURL := wechatOAuthUserInfoURL originalUserInfoURL := wechatOAuthUserInfoURL
t.Cleanup(func() { t.Cleanup(func() {
...@@ -900,10 +863,6 @@ func TestCompleteWeChatOAuthRegistrationRejectsAdoptExistingUserSession(t *testi ...@@ -900,10 +863,6 @@ func TestCompleteWeChatOAuthRegistrationRejectsAdoptExistingUserSession(t *testi
} }
func TestWeChatOAuthCallbackRepairsLegacyProviderKeyCanonicalIdentity(t *testing.T) { func TestWeChatOAuthCallbackRepairsLegacyProviderKeyCanonicalIdentity(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app")
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret")
t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/callback")
originalAccessTokenURL := wechatOAuthAccessTokenURL originalAccessTokenURL := wechatOAuthAccessTokenURL
originalUserInfoURL := wechatOAuthUserInfoURL originalUserInfoURL := wechatOAuthUserInfoURL
t.Cleanup(func() { t.Cleanup(func() {
...@@ -1010,6 +969,22 @@ func TestWeChatOAuthCallbackRepairsLegacyProviderKeyCanonicalIdentity(t *testing ...@@ -1010,6 +969,22 @@ func TestWeChatOAuthCallbackRepairsLegacyProviderKeyCanonicalIdentity(t *testing
} }
func newWeChatOAuthTestHandler(t *testing.T, invitationEnabled bool) (*AuthHandler, *dbent.Client) { func newWeChatOAuthTestHandler(t *testing.T, invitationEnabled bool) (*AuthHandler, *dbent.Client) {
return newWeChatOAuthTestHandlerWithSettings(t, invitationEnabled, nil)
}
func wechatOAuthTestSettings(mode, appID, secret, frontendRedirect string) map[string]string {
return map[string]string{
service.SettingKeyWeChatConnectEnabled: "true",
service.SettingKeyWeChatConnectAppID: appID,
service.SettingKeyWeChatConnectAppSecret: secret,
service.SettingKeyWeChatConnectMode: mode,
service.SettingKeyWeChatConnectScopes: service.DefaultWeChatConnectScopesForMode(mode),
service.SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
service.SettingKeyWeChatConnectFrontendRedirectURL: frontendRedirect,
}
}
func newWeChatOAuthTestHandlerWithSettings(t *testing.T, invitationEnabled bool, extraSettings map[string]string) (*AuthHandler, *dbent.Client) {
t.Helper() t.Helper()
db, err := sql.Open("sqlite", "file:auth_wechat_oauth?mode=memory&cache=shared") db, err := sql.Open("sqlite", "file:auth_wechat_oauth?mode=memory&cache=shared")
...@@ -1036,12 +1011,17 @@ func newWeChatOAuthTestHandler(t *testing.T, invitationEnabled bool) (*AuthHandl ...@@ -1036,12 +1011,17 @@ func newWeChatOAuthTestHandler(t *testing.T, invitationEnabled bool) (*AuthHandl
UserConcurrency: 1, UserConcurrency: 1,
}, },
} }
settingSvc := service.NewSettingService(&wechatOAuthSettingRepoStub{ values := map[string]string{
values: map[string]string{
service.SettingKeyRegistrationEnabled: "true", service.SettingKeyRegistrationEnabled: "true",
service.SettingKeyInvitationCodeEnabled: boolSettingValue(invitationEnabled), service.SettingKeyInvitationCodeEnabled: boolSettingValue(invitationEnabled),
}, }
}, cfg) for key, value := range wechatOAuthTestSettings("open", "wx-open-app", "wx-open-secret", "/auth/wechat/callback") {
values[key] = value
}
for key, value := range extraSettings {
values[key] = value
}
settingSvc := service.NewSettingService(&wechatOAuthSettingRepoStub{values: values}, cfg)
authSvc := service.NewAuthService( authSvc := service.NewAuthService(
client, client,
......
...@@ -51,6 +51,14 @@ type SystemSettings struct { ...@@ -51,6 +51,14 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"` LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"`
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
WeChatConnectEnabled bool `json:"wechat_connect_enabled"`
WeChatConnectAppID string `json:"wechat_connect_app_id"`
WeChatConnectAppSecretConfigured bool `json:"wechat_connect_app_secret_configured"`
WeChatConnectMode string `json:"wechat_connect_mode"`
WeChatConnectScopes string `json:"wechat_connect_scopes"`
WeChatConnectRedirectURL string `json:"wechat_connect_redirect_url"`
WeChatConnectFrontendRedirectURL string `json:"wechat_connect_frontend_redirect_url"`
OIDCConnectEnabled bool `json:"oidc_connect_enabled"` OIDCConnectEnabled bool `json:"oidc_connect_enabled"`
OIDCConnectProviderName string `json:"oidc_connect_provider_name"` OIDCConnectProviderName string `json:"oidc_connect_provider_name"`
OIDCConnectClientID string `json:"oidc_connect_client_id"` OIDCConnectClientID string `json:"oidc_connect_client_id"`
......
...@@ -84,12 +84,17 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t ...@@ -84,12 +84,17 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t
func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) { func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app") h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret") values: map[string]string{
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "") service.SettingKeyWeChatConnectEnabled: "true",
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "") service.SettingKeyWeChatConnectAppID: "wx-mp-app",
service.SettingKeyWeChatConnectAppSecret: "wx-mp-secret",
h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{}, &config.Config{}), "test-version") service.SettingKeyWeChatConnectMode: "mp",
service.SettingKeyWeChatConnectScopes: "snsapi_base",
service.SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
service.SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
},
}, &config.Config{}), "test-version")
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder) c, _ := gin.CreateTestContext(recorder)
...@@ -110,6 +115,6 @@ func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t * ...@@ -110,6 +115,6 @@ func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code) require.Equal(t, 0, resp.Code)
require.True(t, resp.Data.WeChatOAuthEnabled) require.True(t, resp.Data.WeChatOAuthEnabled)
require.True(t, resp.Data.WeChatOAuthOpenEnabled) require.False(t, resp.Data.WeChatOAuthOpenEnabled)
require.False(t, resp.Data.WeChatOAuthMPEnabled) require.True(t, resp.Data.WeChatOAuthMPEnabled)
} }
...@@ -111,6 +111,15 @@ const ( ...@@ -111,6 +111,15 @@ const (
SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret" SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret"
SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url" SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url"
// WeChat Connect OAuth 登录设置
SettingKeyWeChatConnectEnabled = "wechat_connect_enabled"
SettingKeyWeChatConnectAppID = "wechat_connect_app_id"
SettingKeyWeChatConnectAppSecret = "wechat_connect_app_secret"
SettingKeyWeChatConnectMode = "wechat_connect_mode"
SettingKeyWeChatConnectScopes = "wechat_connect_scopes"
SettingKeyWeChatConnectRedirectURL = "wechat_connect_redirect_url"
SettingKeyWeChatConnectFrontendRedirectURL = "wechat_connect_frontend_redirect_url"
// Generic OIDC OAuth 登录设置 // Generic OIDC OAuth 登录设置
SettingKeyOIDCConnectEnabled = "oidc_connect_enabled" SettingKeyOIDCConnectEnabled = "oidc_connect_enabled"
SettingKeyOIDCConnectProviderName = "oidc_connect_provider_name" SettingKeyOIDCConnectProviderName = "oidc_connect_provider_name"
......
...@@ -93,6 +93,11 @@ type UpdatePaymentConfigRequest struct { ...@@ -93,6 +93,11 @@ type UpdatePaymentConfigRequest struct {
CancelRateLimitWindow *int `json:"cancel_rate_limit_window"` CancelRateLimitWindow *int `json:"cancel_rate_limit_window"`
CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"` CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"`
CancelRateLimitMode *string `json:"cancel_rate_limit_window_mode"` CancelRateLimitMode *string `json:"cancel_rate_limit_window_mode"`
VisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"`
VisibleMethodWxpaySource *string `json:"payment_visible_method_wxpay_source"`
VisibleMethodAlipayEnabled *bool `json:"payment_visible_method_alipay_enabled"`
VisibleMethodWxpayEnabled *bool `json:"payment_visible_method_wxpay_enabled"`
} }
// MethodLimits holds per-payment-type limits. // MethodLimits holds per-payment-type limits.
...@@ -319,6 +324,10 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda ...@@ -319,6 +324,10 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow), SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit), SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
SettingCancelWindowMode: derefStr(req.CancelRateLimitMode), SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
SettingPaymentVisibleMethodAlipaySource: derefStr(req.VisibleMethodAlipaySource),
SettingPaymentVisibleMethodWxpaySource: derefStr(req.VisibleMethodWxpaySource),
SettingPaymentVisibleMethodAlipayEnabled: formatBoolOrEmpty(req.VisibleMethodAlipayEnabled),
SettingPaymentVisibleMethodWxpayEnabled: formatBoolOrEmpty(req.VisibleMethodWxpayEnabled),
} }
if req.EnabledTypes != nil { if req.EnabledTypes != nil {
m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",") m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",")
......
...@@ -367,6 +367,7 @@ func newPaymentConfigServiceTestClient(t *testing.T) *dbent.Client { ...@@ -367,6 +367,7 @@ func newPaymentConfigServiceTestClient(t *testing.T) *dbent.Client {
type paymentConfigSettingRepoStub struct { type paymentConfigSettingRepoStub struct {
values map[string]string values map[string]string
updates map[string]string
} }
func (s *paymentConfigSettingRepoStub) Get(context.Context, string) (*Setting, error) { func (s *paymentConfigSettingRepoStub) Get(context.Context, string) (*Setting, error) {
...@@ -383,10 +384,52 @@ func (s *paymentConfigSettingRepoStub) GetMultiple(_ context.Context, keys []str ...@@ -383,10 +384,52 @@ func (s *paymentConfigSettingRepoStub) GetMultiple(_ context.Context, keys []str
} }
return out, nil return out, nil
} }
func (s *paymentConfigSettingRepoStub) SetMultiple(context.Context, map[string]string) error { func (s *paymentConfigSettingRepoStub) SetMultiple(_ context.Context, values map[string]string) error {
s.updates = make(map[string]string, len(values))
for key, value := range values {
s.updates[key] = value
if s.values == nil {
s.values = map[string]string{}
}
s.values[key] = value
}
return nil return nil
} }
func (s *paymentConfigSettingRepoStub) GetAll(context.Context) (map[string]string, error) { func (s *paymentConfigSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
return s.values, nil return s.values, nil
} }
func (s *paymentConfigSettingRepoStub) Delete(context.Context, string) error { return nil } func (s *paymentConfigSettingRepoStub) Delete(context.Context, string) error { return nil }
func TestUpdatePaymentConfig_PersistsVisibleMethodRouting(t *testing.T) {
repo := &paymentConfigSettingRepoStub{values: map[string]string{}}
svc := &PaymentConfigService{settingRepo: repo}
alipayEnabled := true
wxpayEnabled := false
err := svc.UpdatePaymentConfig(context.Background(), UpdatePaymentConfigRequest{
VisibleMethodAlipayEnabled: &alipayEnabled,
VisibleMethodAlipaySource: paymentConfigStrPtr(VisibleMethodSourceEasyPayAlipay),
VisibleMethodWxpayEnabled: &wxpayEnabled,
VisibleMethodWxpaySource: paymentConfigStrPtr(VisibleMethodSourceOfficialWechat),
})
if err != nil {
t.Fatalf("UpdatePaymentConfig returned error: %v", err)
}
if repo.values[SettingPaymentVisibleMethodAlipayEnabled] != "true" {
t.Fatalf("alipay enabled = %q, want true", repo.values[SettingPaymentVisibleMethodAlipayEnabled])
}
if repo.values[SettingPaymentVisibleMethodAlipaySource] != VisibleMethodSourceEasyPayAlipay {
t.Fatalf("alipay source = %q, want %q", repo.values[SettingPaymentVisibleMethodAlipaySource], VisibleMethodSourceEasyPayAlipay)
}
if repo.values[SettingPaymentVisibleMethodWxpayEnabled] != "false" {
t.Fatalf("wxpay enabled = %q, want false", repo.values[SettingPaymentVisibleMethodWxpayEnabled])
}
if repo.values[SettingPaymentVisibleMethodWxpaySource] != VisibleMethodSourceOfficialWechat {
t.Fatalf("wxpay source = %q, want %q", repo.values[SettingPaymentVisibleMethodWxpaySource], VisibleMethodSourceOfficialWechat)
}
}
func paymentConfigStrPtr(value string) *string {
return &value
}
...@@ -6,7 +6,6 @@ import ( ...@@ -6,7 +6,6 @@ import (
"log/slog" "log/slog"
"math" "math"
"net/url" "net/url"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
...@@ -512,16 +511,21 @@ func requiresWeChatJSAPICompatibleSelection(req CreateOrderRequest, sel *payment ...@@ -512,16 +511,21 @@ func requiresWeChatJSAPICompatibleSelection(req CreateOrderRequest, sel *payment
return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != "" return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != ""
} }
func (s *PaymentService) getWeChatPaymentOAuthCredential(context.Context) (string, string, error) { func (s *PaymentService) getWeChatPaymentOAuthCredential(ctx context.Context) (string, string, error) {
appID := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID")) if s == nil || s.configService == nil || s.configService.settingRepo == nil {
appSecret := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET")) return "", "", infraerrors.ServiceUnavailable(
if appID == "" || appSecret == "" { "WECHAT_PAYMENT_MP_NOT_CONFIGURED",
"wechat in-app payment requires a complete WeChat MP OAuth credential",
)
}
cfg, err := (&SettingService{settingRepo: s.configService.settingRepo}).GetWeChatConnectOAuthConfig(ctx)
if err != nil || cfg.Mode != "mp" || strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.AppSecret) == "" {
return "", "", infraerrors.ServiceUnavailable( return "", "", infraerrors.ServiceUnavailable(
"WECHAT_PAYMENT_MP_NOT_CONFIGURED", "WECHAT_PAYMENT_MP_NOT_CONFIGURED",
"wechat in-app payment requires a complete WeChat MP OAuth credential", "wechat in-app payment requires a complete WeChat MP OAuth credential",
) )
} }
return appID, appSecret, nil return strings.TrimSpace(cfg.AppID), strings.TrimSpace(cfg.AppSecret), nil
} }
func classifyCreatePaymentError(req CreateOrderRequest, providerKey string, err error) error { func classifyCreatePaymentError(req CreateOrderRequest, providerKey string, err error) error {
......
...@@ -64,6 +64,13 @@ func TestSelectCreateOrderInstancePrefersJSAPICompatibleWxpayInstance(t *testing ...@@ -64,6 +64,13 @@ func TestSelectCreateOrderInstancePrefersJSAPICompatibleWxpayInstance(t *testing
settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{ settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{
SettingPaymentVisibleMethodWxpayEnabled: "true", SettingPaymentVisibleMethodWxpayEnabled: "true",
SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat, SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
SettingKeyWeChatConnectEnabled: "true",
SettingKeyWeChatConnectAppID: "wx-mp-app",
SettingKeyWeChatConnectAppSecret: "wechat-secret",
SettingKeyWeChatConnectMode: "mp",
SettingKeyWeChatConnectScopes: "snsapi_base",
SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
}}, }},
encryptionKey: []byte(jsapiTestEncryptionKey), encryptionKey: []byte(jsapiTestEncryptionKey),
} }
...@@ -77,9 +84,6 @@ func TestSelectCreateOrderInstancePrefersJSAPICompatibleWxpayInstance(t *testing ...@@ -77,9 +84,6 @@ func TestSelectCreateOrderInstancePrefersJSAPICompatibleWxpayInstance(t *testing
configService: configService, configService: configService,
} }
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx-mp-app")
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret")
sel, err := svc.selectCreateOrderInstance(ctx, CreateOrderRequest{ sel, err := svc.selectCreateOrderInstance(ctx, CreateOrderRequest{
PaymentType: payment.TypeWxpay, PaymentType: payment.TypeWxpay,
OpenID: "openid-123", OpenID: "openid-123",
......
...@@ -91,10 +91,15 @@ func TestBuildCreateOrderResponseCopiesJSAPIPayload(t *testing.T) { ...@@ -91,10 +91,15 @@ func TestBuildCreateOrderResponseCopiesJSAPIPayload(t *testing.T) {
} }
func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) { func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) {
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx123456") svc := newWeChatPaymentOAuthTestService(map[string]string{
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret") SettingKeyWeChatConnectEnabled: "true",
SettingKeyWeChatConnectAppID: "wx123456",
svc := &PaymentService{} SettingKeyWeChatConnectAppSecret: "wechat-secret",
SettingKeyWeChatConnectMode: "mp",
SettingKeyWeChatConnectScopes: "snsapi_base",
SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
})
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{ resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
Amount: 12.5, Amount: 12.5,
...@@ -132,7 +137,7 @@ func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) { ...@@ -132,7 +137,7 @@ func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) {
func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testing.T) { func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testing.T) {
t.Parallel() t.Parallel()
svc := &PaymentService{} svc := newWeChatPaymentOAuthTestService(nil)
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{ resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
Amount: 12.5, Amount: 12.5,
...@@ -155,10 +160,15 @@ func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testin ...@@ -155,10 +160,15 @@ func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testin
} }
func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t *testing.T) { func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t *testing.T) {
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx123456") svc := newWeChatPaymentOAuthTestService(map[string]string{
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret") SettingKeyWeChatConnectEnabled: "true",
SettingKeyWeChatConnectAppID: "wx123456",
svc := &PaymentService{} SettingKeyWeChatConnectAppSecret: "wechat-secret",
SettingKeyWeChatConnectMode: "mp",
SettingKeyWeChatConnectScopes: "snsapi_base",
SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
})
resp, err := svc.maybeBuildWeChatOAuthRequiredResponseForSelection(context.Background(), CreateOrderRequest{ resp, err := svc.maybeBuildWeChatOAuthRequiredResponseForSelection(context.Background(), CreateOrderRequest{
Amount: 12.5, Amount: 12.5,
...@@ -175,3 +185,11 @@ func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t ...@@ -175,3 +185,11 @@ func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t
t.Fatalf("expected nil response, got %+v", resp) t.Fatalf("expected nil response, got %+v", resp)
} }
} }
func newWeChatPaymentOAuthTestService(values map[string]string) *PaymentService {
return &PaymentService{
configService: &PaymentConfigService{
settingRepo: &paymentConfigSettingRepoStub{values: values},
},
}
}
...@@ -9,7 +9,6 @@ import ( ...@@ -9,7 +9,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/url" "net/url"
"os"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
...@@ -173,8 +172,43 @@ var ( ...@@ -173,8 +172,43 @@ var (
const ( const (
defaultAuthSourceBalance = 0 defaultAuthSourceBalance = 0
defaultAuthSourceConcurrency = 5 defaultAuthSourceConcurrency = 5
defaultWeChatConnectMode = "open"
defaultWeChatConnectScopes = "snsapi_login"
defaultWeChatConnectFrontend = "/auth/wechat/callback"
) )
func normalizeWeChatConnectModeSetting(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "mp":
return "mp"
default:
return "open"
}
}
func defaultWeChatConnectScopeForMode(mode string) string {
if normalizeWeChatConnectModeSetting(mode) == "mp" {
return "snsapi_userinfo"
}
return defaultWeChatConnectScopes
}
func normalizeWeChatConnectScopeSetting(raw, mode string) string {
switch normalizeWeChatConnectModeSetting(mode) {
case "mp":
switch strings.TrimSpace(raw) {
case "snsapi_base":
return "snsapi_base"
case "snsapi_userinfo":
return "snsapi_userinfo"
default:
return defaultWeChatConnectScopeForMode(mode)
}
default:
return defaultWeChatConnectScopes
}
}
// NewSettingService 创建系统设置服务实例 // NewSettingService 创建系统设置服务实例
func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService { func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService {
return &SettingService{ return &SettingService{
...@@ -240,6 +274,13 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -240,6 +274,13 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyCustomMenuItems, SettingKeyCustomMenuItems,
SettingKeyCustomEndpoints, SettingKeyCustomEndpoints,
SettingKeyLinuxDoConnectEnabled, SettingKeyLinuxDoConnectEnabled,
SettingKeyWeChatConnectEnabled,
SettingKeyWeChatConnectAppID,
SettingKeyWeChatConnectAppSecret,
SettingKeyWeChatConnectMode,
SettingKeyWeChatConnectScopes,
SettingKeyWeChatConnectRedirectURL,
SettingKeyWeChatConnectFrontendRedirectURL,
SettingKeyBackendModeEnabled, SettingKeyBackendModeEnabled,
SettingPaymentEnabled, SettingPaymentEnabled,
SettingKeyOIDCConnectEnabled, SettingKeyOIDCConnectEnabled,
...@@ -274,9 +315,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ...@@ -274,9 +315,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
if oidcProviderName == "" { if oidcProviderName == "" {
oidcProviderName = "OIDC" oidcProviderName = "OIDC"
} }
weChatOpenEnabled := isWeChatOAuthOpenConfigured() weChatEnabled, weChatOpenEnabled, weChatMPEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
weChatMPEnabled := isWeChatOAuthMPConfigured()
weChatEnabled := weChatOpenEnabled || weChatMPEnabled
// Password reset requires email verification to be enabled // Password reset requires email verification to be enabled
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
...@@ -431,6 +470,56 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ...@@ -431,6 +470,56 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
}, nil }, nil
} }
func DefaultWeChatConnectScopesForMode(mode string) string {
return defaultWeChatConnectScopeForMode(mode)
}
func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) {
cfg := WeChatConnectOAuthConfig{
Enabled: settings[SettingKeyWeChatConnectEnabled] == "true",
AppID: strings.TrimSpace(settings[SettingKeyWeChatConnectAppID]),
AppSecret: strings.TrimSpace(settings[SettingKeyWeChatConnectAppSecret]),
Mode: normalizeWeChatConnectModeSetting(settings[SettingKeyWeChatConnectMode]),
Scopes: normalizeWeChatConnectScopeSetting(settings[SettingKeyWeChatConnectScopes], settings[SettingKeyWeChatConnectMode]),
RedirectURL: strings.TrimSpace(settings[SettingKeyWeChatConnectRedirectURL]),
FrontendRedirectURL: strings.TrimSpace(settings[SettingKeyWeChatConnectFrontendRedirectURL]),
}
if cfg.FrontendRedirectURL == "" {
cfg.FrontendRedirectURL = defaultWeChatConnectFrontend
}
if !cfg.Enabled {
return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled")
}
if cfg.AppID == "" {
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth app id not configured")
}
if cfg.AppSecret == "" {
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth app secret not configured")
}
if cfg.RedirectURL == "" {
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url not configured")
}
if cfg.FrontendRedirectURL == "" {
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url not configured")
}
if err := config.ValidateAbsoluteHTTPURL(cfg.RedirectURL); err != nil {
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url invalid")
}
if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url invalid")
}
return cfg, nil
}
func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool) {
cfg, err := s.parseWeChatConnectOAuthConfig(settings)
if err != nil {
return false, false, false
}
return true, cfg.Mode == "open", cfg.Mode == "mp"
}
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON // filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
// array string, returning only items with visibility != "admin". // array string, returning only items with visibility != "admin".
func filterUserVisibleMenuItems(raw string) json.RawMessage { func filterUserVisibleMenuItems(raw string) json.RawMessage {
...@@ -467,20 +556,6 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage { ...@@ -467,20 +556,6 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage {
return result return result
} }
func isWeChatOAuthConfigured() bool {
return isWeChatOAuthOpenConfigured() || isWeChatOAuthMPConfigured()
}
func isWeChatOAuthOpenConfigured() bool {
return strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_ID")) != "" &&
strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_SECRET")) != ""
}
func isWeChatOAuthMPConfigured() bool {
return strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID")) != "" &&
strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET")) != ""
}
// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]". // safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
func safeRawJSONArray(raw string) json.RawMessage { func safeRawJSONArray(raw string) json.RawMessage {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
...@@ -625,6 +700,15 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting ...@@ -625,6 +700,15 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
} }
settings.PaymentVisibleMethodAlipaySource = alipaySource settings.PaymentVisibleMethodAlipaySource = alipaySource
settings.PaymentVisibleMethodWxpaySource = wxpaySource settings.PaymentVisibleMethodWxpaySource = wxpaySource
settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID)
settings.WeChatConnectAppSecret = strings.TrimSpace(settings.WeChatConnectAppSecret)
settings.WeChatConnectMode = normalizeWeChatConnectModeSetting(settings.WeChatConnectMode)
settings.WeChatConnectScopes = normalizeWeChatConnectScopeSetting(settings.WeChatConnectScopes, settings.WeChatConnectMode)
settings.WeChatConnectRedirectURL = strings.TrimSpace(settings.WeChatConnectRedirectURL)
settings.WeChatConnectFrontendRedirectURL = strings.TrimSpace(settings.WeChatConnectFrontendRedirectURL)
if settings.WeChatConnectFrontendRedirectURL == "" {
settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend
}
updates := make(map[string]string) updates := make(map[string]string)
...@@ -694,6 +778,17 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting ...@@ -694,6 +778,17 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret
} }
// WeChat Connect OAuth 登录
updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled)
updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID
updates[SettingKeyWeChatConnectMode] = settings.WeChatConnectMode
updates[SettingKeyWeChatConnectScopes] = settings.WeChatConnectScopes
updates[SettingKeyWeChatConnectRedirectURL] = settings.WeChatConnectRedirectURL
updates[SettingKeyWeChatConnectFrontendRedirectURL] = settings.WeChatConnectFrontendRedirectURL
if settings.WeChatConnectAppSecret != "" {
updates[SettingKeyWeChatConnectAppSecret] = settings.WeChatConnectAppSecret
}
// OEM设置 // OEM设置
updates[SettingKeySiteName] = settings.SiteName updates[SettingKeySiteName] = settings.SiteName
updates[SettingKeySiteLogo] = settings.SiteLogo updates[SettingKeySiteLogo] = settings.SiteLogo
...@@ -1200,6 +1295,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { ...@@ -1200,6 +1295,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyTablePageSizeOptions: "[10,20,50,100]", SettingKeyTablePageSizeOptions: "[10,20,50,100]",
SettingKeyCustomMenuItems: "[]", SettingKeyCustomMenuItems: "[]",
SettingKeyCustomEndpoints: "[]", SettingKeyCustomEndpoints: "[]",
SettingKeyWeChatConnectEnabled: "false",
SettingKeyWeChatConnectMode: "open",
SettingKeyWeChatConnectScopes: "snsapi_login",
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
SettingKeyOIDCConnectEnabled: "false", SettingKeyOIDCConnectEnabled: "false",
SettingKeyOIDCConnectProviderName: "OIDC", SettingKeyOIDCConnectProviderName: "OIDC",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
...@@ -1491,6 +1590,19 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin ...@@ -1491,6 +1590,19 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
} }
result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != "" result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != ""
// WeChat Connect 设置:完全以 DB 系统设置为准。
result.WeChatConnectEnabled = settings[SettingKeyWeChatConnectEnabled] == "true"
result.WeChatConnectAppID = strings.TrimSpace(settings[SettingKeyWeChatConnectAppID])
result.WeChatConnectAppSecret = strings.TrimSpace(settings[SettingKeyWeChatConnectAppSecret])
result.WeChatConnectAppSecretConfigured = result.WeChatConnectAppSecret != ""
result.WeChatConnectMode = normalizeWeChatConnectModeSetting(settings[SettingKeyWeChatConnectMode])
result.WeChatConnectScopes = normalizeWeChatConnectScopeSetting(settings[SettingKeyWeChatConnectScopes], settings[SettingKeyWeChatConnectMode])
result.WeChatConnectRedirectURL = strings.TrimSpace(settings[SettingKeyWeChatConnectRedirectURL])
result.WeChatConnectFrontendRedirectURL = strings.TrimSpace(settings[SettingKeyWeChatConnectFrontendRedirectURL])
if result.WeChatConnectFrontendRedirectURL == "" {
result.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend
}
// Model fallback settings // Model fallback settings
result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true" result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true"
result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022") result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022")
...@@ -1972,6 +2084,26 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf ...@@ -1972,6 +2084,26 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf
return effective, nil return effective, nil
} }
// GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。
//
// WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。
func (s *SettingService) GetWeChatConnectOAuthConfig(ctx context.Context) (WeChatConnectOAuthConfig, error) {
keys := []string{
SettingKeyWeChatConnectEnabled,
SettingKeyWeChatConnectAppID,
SettingKeyWeChatConnectAppSecret,
SettingKeyWeChatConnectMode,
SettingKeyWeChatConnectScopes,
SettingKeyWeChatConnectRedirectURL,
SettingKeyWeChatConnectFrontendRedirectURL,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil {
return WeChatConnectOAuthConfig{}, fmt.Errorf("get wechat connect settings: %w", err)
}
return s.parseWeChatConnectOAuthConfig(settings)
}
// GetOverloadCooldownSettings 获取529过载冷却配置 // GetOverloadCooldownSettings 获取529过载冷却配置
func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) { func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) {
value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings) value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings)
......
...@@ -92,16 +92,21 @@ func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t ...@@ -92,16 +92,21 @@ func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t
} }
func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) { func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app") svc := NewSettingService(&settingPublicRepoStub{
t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret") values: map[string]string{
t.Setenv("WECHAT_OAUTH_MP_APP_ID", "") SettingKeyWeChatConnectEnabled: "true",
t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "") SettingKeyWeChatConnectAppID: "wx-mp-app",
SettingKeyWeChatConnectAppSecret: "wx-mp-secret",
svc := NewSettingService(&settingPublicRepoStub{}, &config.Config{}) SettingKeyWeChatConnectMode: "mp",
SettingKeyWeChatConnectScopes: "snsapi_base",
SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
},
}, &config.Config{})
settings, err := svc.GetPublicSettings(context.Background()) settings, err := svc.GetPublicSettings(context.Background())
require.NoError(t, err) require.NoError(t, err)
require.True(t, settings.WeChatOAuthEnabled) require.True(t, settings.WeChatOAuthEnabled)
require.True(t, settings.WeChatOAuthOpenEnabled) require.False(t, settings.WeChatOAuthOpenEnabled)
require.False(t, settings.WeChatOAuthMPEnabled) require.True(t, settings.WeChatOAuthMPEnabled)
} }
//go:build unit
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
type settingWeChatRepoStub struct {
values map[string]string
}
func (s *settingWeChatRepoStub) Get(context.Context, string) (*Setting, error) {
panic("unexpected Get call")
}
func (s *settingWeChatRepoStub) GetValue(_ context.Context, key string) (string, error) {
if value, ok := s.values[key]; ok {
return value, nil
}
return "", ErrSettingNotFound
}
func (s *settingWeChatRepoStub) Set(context.Context, string, string) error {
panic("unexpected Set call")
}
func (s *settingWeChatRepoStub) GetMultiple(_ 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 *settingWeChatRepoStub) SetMultiple(context.Context, map[string]string) error {
panic("unexpected SetMultiple call")
}
func (s *settingWeChatRepoStub) GetAll(context.Context) (map[string]string, error) {
panic("unexpected GetAll call")
}
func (s *settingWeChatRepoStub) Delete(context.Context, string) error {
panic("unexpected Delete call")
}
func TestSettingService_GetWeChatConnectOAuthConfig_UsesDatabaseOverrides(t *testing.T) {
repo := &settingWeChatRepoStub{
values: map[string]string{
SettingKeyWeChatConnectEnabled: "true",
SettingKeyWeChatConnectAppID: "wx-db-app",
SettingKeyWeChatConnectAppSecret: "wx-db-secret",
SettingKeyWeChatConnectMode: "mp",
SettingKeyWeChatConnectScopes: "snsapi_base",
SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
},
}
svc := NewSettingService(repo, &config.Config{})
got, err := svc.GetWeChatConnectOAuthConfig(context.Background())
require.NoError(t, err)
require.True(t, got.Enabled)
require.Equal(t, "wx-db-app", got.AppID)
require.Equal(t, "wx-db-secret", got.AppSecret)
require.Equal(t, "mp", got.Mode)
require.Equal(t, "snsapi_base", got.Scopes)
require.Equal(t, "https://api.example.com/api/v1/auth/oauth/wechat/callback", got.RedirectURL)
require.Equal(t, "/auth/wechat/callback", got.FrontendRedirectURL)
}
...@@ -31,6 +31,16 @@ type SystemSettings struct { ...@@ -31,6 +31,16 @@ type SystemSettings struct {
LinuxDoConnectClientSecretConfigured bool LinuxDoConnectClientSecretConfigured bool
LinuxDoConnectRedirectURL string LinuxDoConnectRedirectURL string
// WeChat Connect OAuth 登录
WeChatConnectEnabled bool
WeChatConnectAppID string
WeChatConnectAppSecret string
WeChatConnectAppSecretConfigured bool
WeChatConnectMode string
WeChatConnectScopes string
WeChatConnectRedirectURL string
WeChatConnectFrontendRedirectURL string
// Generic OIDC OAuth 登录 // Generic OIDC OAuth 登录
OIDCConnectEnabled bool OIDCConnectEnabled bool
OIDCConnectProviderName string OIDCConnectProviderName string
...@@ -177,6 +187,16 @@ type PublicSettings struct { ...@@ -177,6 +187,16 @@ type PublicSettings struct {
BalanceLowNotifyRechargeURL string BalanceLowNotifyRechargeURL string
} }
type WeChatConnectOAuthConfig struct {
Enabled bool
AppID string
AppSecret string
Mode string
Scopes string
RedirectURL string
FrontendRedirectURL string
}
// StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制) // StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制)
type StreamTimeoutSettings struct { type StreamTimeoutSettings struct {
// Enabled 是否启用流超时处理 // Enabled 是否启用流超时处理
......
import { describe, expect, it } from "vitest";
import {
defaultWeChatConnectScopesForMode,
normalizeWeChatConnectMode,
} from "@/api/admin/settings";
describe("admin settings wechat connect helpers", () => {
it("normalizes legacy or noisy mode values to the backend contract", () => {
expect(normalizeWeChatConnectMode("OPEN")).toBe("open");
expect(normalizeWeChatConnectMode(" open_platform ")).toBe("open");
expect(normalizeWeChatConnectMode("mp")).toBe("mp");
expect(normalizeWeChatConnectMode("official_account")).toBe("mp");
expect(normalizeWeChatConnectMode("unknown")).toBe("open");
});
it("maps each mode to the backend default scopes", () => {
expect(defaultWeChatConnectScopesForMode("open")).toBe("snsapi_login");
expect(defaultWeChatConnectScopesForMode("mp")).toBe("snsapi_userinfo");
});
});
...@@ -3,155 +3,240 @@ ...@@ -3,155 +3,240 @@
* Handles system settings management for administrators * Handles system settings management for administrators
*/ */
import { apiClient } from '../client' import { apiClient } from "../client";
import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from '@/types' import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from "@/types";
export interface DefaultSubscriptionSetting { export interface DefaultSubscriptionSetting {
group_id: number group_id: number;
validity_days: number validity_days: number;
} }
export type AuthSourceType = 'email' | 'linuxdo' | 'oidc' | 'wechat' export type AuthSourceType = "email" | "linuxdo" | "oidc" | "wechat";
export interface AuthSourceDefaultsValue { export interface AuthSourceDefaultsValue {
balance: number balance: number;
concurrency: number concurrency: number;
subscriptions: DefaultSubscriptionSetting[] subscriptions: DefaultSubscriptionSetting[];
grant_on_signup: boolean grant_on_signup: boolean;
grant_on_first_bind: boolean grant_on_first_bind: boolean;
} }
export type AuthSourceDefaultsState = Record<AuthSourceType, AuthSourceDefaultsValue> export type AuthSourceDefaultsState = Record<
export type PaymentVisibleMethod = 'alipay' | 'wxpay' AuthSourceType,
AuthSourceDefaultsValue
>;
export type PaymentVisibleMethod = "alipay" | "wxpay";
export type PaymentVisibleMethodSource = export type PaymentVisibleMethodSource =
| '' | ""
| 'official_alipay' | "official_alipay"
| 'easypay_alipay' | "easypay_alipay"
| 'official_wxpay' | "official_wxpay"
| 'easypay_wxpay' | "easypay_wxpay";
export type WeChatConnectMode = "open" | "mp";
export interface PaymentVisibleMethodSourceOption { export interface PaymentVisibleMethodSourceOption {
value: PaymentVisibleMethodSource value: PaymentVisibleMethodSource;
labelZh: string labelZh: string;
labelEn: string labelEn: string;
}
export interface WeChatConnectModeOption {
value: WeChatConnectMode;
labelZh: string;
labelEn: string;
} }
const AUTH_SOURCE_TYPES: AuthSourceType[] = ['email', 'linuxdo', 'oidc', 'wechat'] const AUTH_SOURCE_TYPES: AuthSourceType[] = [
const AUTH_SOURCE_DEFAULT_BALANCE = 0 "email",
const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5 "linuxdo",
"oidc",
"wechat",
];
const AUTH_SOURCE_DEFAULT_BALANCE = 0;
const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5;
const PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS: Record< const PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS: Record<
PaymentVisibleMethod, PaymentVisibleMethod,
PaymentVisibleMethodSourceOption[] PaymentVisibleMethodSourceOption[]
> = { > = {
alipay: [ alipay: [
{ value: '', labelZh: '未配置', labelEn: 'Not configured' }, { value: "", labelZh: "未配置", labelEn: "Not configured" },
{ value: 'official_alipay', labelZh: '支付宝官方', labelEn: 'Official Alipay' }, {
{ value: 'easypay_alipay', labelZh: '易支付支付宝', labelEn: 'EasyPay Alipay' }, value: "official_alipay",
labelZh: "支付宝官方",
labelEn: "Official Alipay",
},
{
value: "easypay_alipay",
labelZh: "易支付支付宝",
labelEn: "EasyPay Alipay",
},
], ],
wxpay: [ wxpay: [
{ value: '', labelZh: '未配置', labelEn: 'Not configured' }, { value: "", labelZh: "未配置", labelEn: "Not configured" },
{ value: 'official_wxpay', labelZh: '微信官方', labelEn: 'Official WeChat Pay' }, {
{ value: 'easypay_wxpay', labelZh: '易支付微信', labelEn: 'EasyPay WeChat Pay' }, value: "official_wxpay",
labelZh: "微信官方",
labelEn: "Official WeChat Pay",
},
{
value: "easypay_wxpay",
labelZh: "易支付微信",
labelEn: "EasyPay WeChat Pay",
},
], ],
} };
const PAYMENT_VISIBLE_METHOD_SOURCE_ALIASES: Record< const PAYMENT_VISIBLE_METHOD_SOURCE_ALIASES: Record<
PaymentVisibleMethod, PaymentVisibleMethod,
Record<string, PaymentVisibleMethodSource> Record<string, PaymentVisibleMethodSource>
> = { > = {
alipay: { alipay: {
official_alipay: 'official_alipay', official_alipay: "official_alipay",
alipay: 'official_alipay', alipay: "official_alipay",
alipay_direct: 'official_alipay', alipay_direct: "official_alipay",
official: 'official_alipay', official: "official_alipay",
easypay_alipay: 'easypay_alipay', easypay_alipay: "easypay_alipay",
easypay: 'easypay_alipay', easypay: "easypay_alipay",
}, },
wxpay: { wxpay: {
official_wxpay: 'official_wxpay', official_wxpay: "official_wxpay",
wxpay: 'official_wxpay', wxpay: "official_wxpay",
wxpay_direct: 'official_wxpay', wxpay_direct: "official_wxpay",
wechat: 'official_wxpay', wechat: "official_wxpay",
official: 'official_wxpay', official: "official_wxpay",
easypay_wxpay: 'easypay_wxpay', easypay_wxpay: "easypay_wxpay",
easypay: 'easypay_wxpay', easypay: "easypay_wxpay",
}, },
} };
const WECHAT_CONNECT_MODE_OPTIONS: WeChatConnectModeOption[] = [
{ value: "open", labelZh: "微信开放平台", labelEn: "WeChat Open Platform" },
{
value: "mp",
labelZh: "微信公众号 / 小程序",
labelEn: "WeChat Official Account / Mini Program",
},
];
const WECHAT_CONNECT_MODE_ALIASES: Record<string, WeChatConnectMode> = {
open: "open",
open_platform: "open",
official: "open",
wx_open: "open",
mp: "mp",
official_account: "mp",
wechat_mp: "mp",
mini_program: "mp",
};
export function normalizeDefaultSubscriptionSettings( export function normalizeDefaultSubscriptionSettings(
subscriptions: DefaultSubscriptionSetting[] | null | undefined subscriptions: DefaultSubscriptionSetting[] | null | undefined,
): DefaultSubscriptionSetting[] { ): DefaultSubscriptionSetting[] {
if (!Array.isArray(subscriptions)) return [] if (!Array.isArray(subscriptions)) return [];
return subscriptions return subscriptions
.filter((item) => item.group_id > 0 && item.validity_days > 0) .filter((item) => item.group_id > 0 && item.validity_days > 0)
.map((item) => ({ .map((item) => ({
group_id: Math.floor(item.group_id), group_id: Math.floor(item.group_id),
validity_days: Math.min(36500, Math.max(1, Math.floor(item.validity_days))) validity_days: Math.min(
})) 36500,
Math.max(1, Math.floor(item.validity_days)),
),
}));
} }
export function buildAuthSourceDefaultsState( export function buildAuthSourceDefaultsState(
settings: Partial<SystemSettings> settings: Partial<SystemSettings>,
): AuthSourceDefaultsState { ): AuthSourceDefaultsState {
const raw = settings as Record<string, unknown> const raw = settings as Record<string, unknown>;
return AUTH_SOURCE_TYPES.reduce((acc, source) => { return AUTH_SOURCE_TYPES.reduce((acc, source) => {
const subscriptions = raw[`auth_source_default_${source}_subscriptions`] const subscriptions = raw[`auth_source_default_${source}_subscriptions`];
acc[source] = { acc[source] = {
balance: Number(raw[`auth_source_default_${source}_balance`] ?? AUTH_SOURCE_DEFAULT_BALANCE), balance: Number(
raw[`auth_source_default_${source}_balance`] ??
AUTH_SOURCE_DEFAULT_BALANCE,
),
concurrency: Math.max( concurrency: Math.max(
1, 1,
Number(raw[`auth_source_default_${source}_concurrency`] ?? AUTH_SOURCE_DEFAULT_CONCURRENCY) Number(
raw[`auth_source_default_${source}_concurrency`] ??
AUTH_SOURCE_DEFAULT_CONCURRENCY,
),
), ),
subscriptions: normalizeDefaultSubscriptionSettings( subscriptions: normalizeDefaultSubscriptionSettings(
Array.isArray(subscriptions) ? (subscriptions as DefaultSubscriptionSetting[]) : [] Array.isArray(subscriptions)
? (subscriptions as DefaultSubscriptionSetting[])
: [],
), ),
grant_on_signup: raw[`auth_source_default_${source}_grant_on_signup`] !== false, grant_on_signup:
grant_on_first_bind: raw[`auth_source_default_${source}_grant_on_first_bind`] === true, raw[`auth_source_default_${source}_grant_on_signup`] !== false,
} grant_on_first_bind:
return acc raw[`auth_source_default_${source}_grant_on_first_bind`] === true,
}, {} as AuthSourceDefaultsState) };
return acc;
}, {} as AuthSourceDefaultsState);
} }
export function appendAuthSourceDefaultsToUpdateRequest( export function appendAuthSourceDefaultsToUpdateRequest(
payload: UpdateSettingsRequest, payload: UpdateSettingsRequest,
authSourceDefaults: AuthSourceDefaultsState authSourceDefaults: AuthSourceDefaultsState,
): UpdateSettingsRequest { ): UpdateSettingsRequest {
const target = payload as Record<string, unknown> const target = payload as Record<string, unknown>;
for (const source of AUTH_SOURCE_TYPES) { for (const source of AUTH_SOURCE_TYPES) {
const current = authSourceDefaults[source] const current = authSourceDefaults[source];
target[`auth_source_default_${source}_balance`] = Number(current.balance) || 0 target[`auth_source_default_${source}_balance`] =
Number(current.balance) || 0;
target[`auth_source_default_${source}_concurrency`] = Math.max( target[`auth_source_default_${source}_concurrency`] = Math.max(
1, 1,
Math.floor(Number(current.concurrency) || AUTH_SOURCE_DEFAULT_CONCURRENCY) Math.floor(
) Number(current.concurrency) || AUTH_SOURCE_DEFAULT_CONCURRENCY,
target[`auth_source_default_${source}_subscriptions`] = normalizeDefaultSubscriptionSettings( ),
current.subscriptions );
) target[`auth_source_default_${source}_subscriptions`] =
target[`auth_source_default_${source}_grant_on_signup`] = current.grant_on_signup normalizeDefaultSubscriptionSettings(current.subscriptions);
target[`auth_source_default_${source}_grant_on_first_bind`] = current.grant_on_first_bind target[`auth_source_default_${source}_grant_on_signup`] =
current.grant_on_signup;
target[`auth_source_default_${source}_grant_on_first_bind`] =
current.grant_on_first_bind;
} }
return payload return payload;
} }
export function getPaymentVisibleMethodSourceOptions( export function getPaymentVisibleMethodSourceOptions(
method: PaymentVisibleMethod method: PaymentVisibleMethod,
): PaymentVisibleMethodSourceOption[] { ): PaymentVisibleMethodSourceOption[] {
return PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS[method] return PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS[method];
} }
export function normalizePaymentVisibleMethodSource( export function normalizePaymentVisibleMethodSource(
method: PaymentVisibleMethod, method: PaymentVisibleMethod,
source: unknown source: unknown,
): PaymentVisibleMethodSource { ): PaymentVisibleMethodSource {
if (typeof source !== 'string') return '' if (typeof source !== "string") return "";
const normalized = source.trim().toLowerCase() const normalized = source.trim().toLowerCase();
if (!normalized) return '' if (!normalized) return "";
return PAYMENT_VISIBLE_METHOD_SOURCE_ALIASES[method][normalized] ?? '' return PAYMENT_VISIBLE_METHOD_SOURCE_ALIASES[method][normalized] ?? "";
}
export function getWeChatConnectModeOptions(): WeChatConnectModeOption[] {
return WECHAT_CONNECT_MODE_OPTIONS;
}
export function normalizeWeChatConnectMode(source: unknown): WeChatConnectMode {
if (typeof source !== "string") return "open";
const normalized = source.trim().toLowerCase();
if (!normalized) return "open";
return WECHAT_CONNECT_MODE_ALIASES[normalized] ?? "open";
}
export function defaultWeChatConnectScopesForMode(mode: unknown): string {
return normalizeWeChatConnectMode(mode) === "mp"
? "snsapi_userinfo"
: "snsapi_login";
} }
/** /**
...@@ -159,293 +244,309 @@ export function normalizePaymentVisibleMethodSource( ...@@ -159,293 +244,309 @@ export function normalizePaymentVisibleMethodSource(
*/ */
export interface SystemSettings { export interface SystemSettings {
// Registration settings // Registration settings
registration_enabled: boolean registration_enabled: boolean;
email_verify_enabled: boolean email_verify_enabled: boolean;
registration_email_suffix_whitelist: string[] registration_email_suffix_whitelist: string[];
promo_code_enabled: boolean promo_code_enabled: boolean;
password_reset_enabled: boolean password_reset_enabled: boolean;
frontend_url: string frontend_url: string;
invitation_code_enabled: boolean invitation_code_enabled: boolean;
totp_enabled: boolean // TOTP 双因素认证 totp_enabled: boolean; // TOTP 双因素认证
totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置 totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
// Default settings // Default settings
default_balance: number default_balance: number;
default_concurrency: number default_concurrency: number;
default_subscriptions: DefaultSubscriptionSetting[] default_subscriptions: DefaultSubscriptionSetting[];
auth_source_default_email_balance?: number auth_source_default_email_balance?: number;
auth_source_default_email_concurrency?: number auth_source_default_email_concurrency?: number;
auth_source_default_email_subscriptions?: DefaultSubscriptionSetting[] auth_source_default_email_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_email_grant_on_signup?: boolean auth_source_default_email_grant_on_signup?: boolean;
auth_source_default_email_grant_on_first_bind?: boolean auth_source_default_email_grant_on_first_bind?: boolean;
auth_source_default_linuxdo_balance?: number auth_source_default_linuxdo_balance?: number;
auth_source_default_linuxdo_concurrency?: number auth_source_default_linuxdo_concurrency?: number;
auth_source_default_linuxdo_subscriptions?: DefaultSubscriptionSetting[] auth_source_default_linuxdo_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_linuxdo_grant_on_signup?: boolean auth_source_default_linuxdo_grant_on_signup?: boolean;
auth_source_default_linuxdo_grant_on_first_bind?: boolean auth_source_default_linuxdo_grant_on_first_bind?: boolean;
auth_source_default_oidc_balance?: number auth_source_default_oidc_balance?: number;
auth_source_default_oidc_concurrency?: number auth_source_default_oidc_concurrency?: number;
auth_source_default_oidc_subscriptions?: DefaultSubscriptionSetting[] auth_source_default_oidc_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_oidc_grant_on_signup?: boolean auth_source_default_oidc_grant_on_signup?: boolean;
auth_source_default_oidc_grant_on_first_bind?: boolean auth_source_default_oidc_grant_on_first_bind?: boolean;
auth_source_default_wechat_balance?: number auth_source_default_wechat_balance?: number;
auth_source_default_wechat_concurrency?: number auth_source_default_wechat_concurrency?: number;
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[] auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_wechat_grant_on_signup?: boolean auth_source_default_wechat_grant_on_signup?: boolean;
auth_source_default_wechat_grant_on_first_bind?: boolean auth_source_default_wechat_grant_on_first_bind?: boolean;
force_email_on_third_party_signup?: boolean force_email_on_third_party_signup?: boolean;
// OEM settings // OEM settings
site_name: string site_name: string;
site_logo: string site_logo: string;
site_subtitle: string site_subtitle: string;
api_base_url: string api_base_url: string;
contact_info: string contact_info: string;
doc_url: string doc_url: string;
home_content: string home_content: string;
hide_ccs_import_button: boolean hide_ccs_import_button: boolean;
table_default_page_size: number table_default_page_size: number;
table_page_size_options: number[] table_page_size_options: number[];
backend_mode_enabled: boolean backend_mode_enabled: boolean;
custom_menu_items: CustomMenuItem[] custom_menu_items: CustomMenuItem[];
custom_endpoints: CustomEndpoint[] custom_endpoints: CustomEndpoint[];
// SMTP settings // SMTP settings
smtp_host: string smtp_host: string;
smtp_port: number smtp_port: number;
smtp_username: string smtp_username: string;
smtp_password_configured: boolean smtp_password_configured: boolean;
smtp_from_email: string smtp_from_email: string;
smtp_from_name: string smtp_from_name: string;
smtp_use_tls: boolean smtp_use_tls: boolean;
// Cloudflare Turnstile settings // Cloudflare Turnstile settings
turnstile_enabled: boolean turnstile_enabled: boolean;
turnstile_site_key: string turnstile_site_key: string;
turnstile_secret_key_configured: boolean turnstile_secret_key_configured: boolean;
// LinuxDo Connect OAuth settings // LinuxDo Connect OAuth settings
linuxdo_connect_enabled: boolean linuxdo_connect_enabled: boolean;
linuxdo_connect_client_id: string linuxdo_connect_client_id: string;
linuxdo_connect_client_secret_configured: boolean linuxdo_connect_client_secret_configured: boolean;
linuxdo_connect_redirect_url: string linuxdo_connect_redirect_url: string;
// WeChat Connect OAuth settings
wechat_connect_enabled: boolean;
wechat_connect_app_id: string;
wechat_connect_app_secret_configured: boolean;
wechat_connect_mode: string;
wechat_connect_scopes: string;
wechat_connect_redirect_url: string;
wechat_connect_frontend_redirect_url: string;
// Generic OIDC OAuth settings // Generic OIDC OAuth settings
oidc_connect_enabled: boolean oidc_connect_enabled: boolean;
oidc_connect_provider_name: string oidc_connect_provider_name: string;
oidc_connect_client_id: string oidc_connect_client_id: string;
oidc_connect_client_secret_configured: boolean oidc_connect_client_secret_configured: boolean;
oidc_connect_issuer_url: string oidc_connect_issuer_url: string;
oidc_connect_discovery_url: string oidc_connect_discovery_url: string;
oidc_connect_authorize_url: string oidc_connect_authorize_url: string;
oidc_connect_token_url: string oidc_connect_token_url: string;
oidc_connect_userinfo_url: string oidc_connect_userinfo_url: string;
oidc_connect_jwks_url: string oidc_connect_jwks_url: string;
oidc_connect_scopes: string oidc_connect_scopes: string;
oidc_connect_redirect_url: string oidc_connect_redirect_url: string;
oidc_connect_frontend_redirect_url: string oidc_connect_frontend_redirect_url: string;
oidc_connect_token_auth_method: string oidc_connect_token_auth_method: string;
oidc_connect_use_pkce: boolean oidc_connect_use_pkce: boolean;
oidc_connect_validate_id_token: boolean oidc_connect_validate_id_token: boolean;
oidc_connect_allowed_signing_algs: string oidc_connect_allowed_signing_algs: string;
oidc_connect_clock_skew_seconds: number oidc_connect_clock_skew_seconds: number;
oidc_connect_require_email_verified: boolean oidc_connect_require_email_verified: boolean;
oidc_connect_userinfo_email_path: string oidc_connect_userinfo_email_path: string;
oidc_connect_userinfo_id_path: string oidc_connect_userinfo_id_path: string;
oidc_connect_userinfo_username_path: string oidc_connect_userinfo_username_path: string;
// Model fallback configuration // Model fallback configuration
enable_model_fallback: boolean enable_model_fallback: boolean;
fallback_model_anthropic: string fallback_model_anthropic: string;
fallback_model_openai: string fallback_model_openai: string;
fallback_model_gemini: string fallback_model_gemini: string;
fallback_model_antigravity: string fallback_model_antigravity: string;
// Identity patch configuration (Claude -> Gemini) // Identity patch configuration (Claude -> Gemini)
enable_identity_patch: boolean enable_identity_patch: boolean;
identity_patch_prompt: string identity_patch_prompt: string;
// Ops Monitoring (vNext) // Ops Monitoring (vNext)
ops_monitoring_enabled: boolean ops_monitoring_enabled: boolean;
ops_realtime_monitoring_enabled: boolean ops_realtime_monitoring_enabled: boolean;
ops_query_mode_default: 'auto' | 'raw' | 'preagg' | string ops_query_mode_default: "auto" | "raw" | "preagg" | string;
ops_metrics_interval_seconds: number ops_metrics_interval_seconds: number;
// Claude Code version check // Claude Code version check
min_claude_code_version: string min_claude_code_version: string;
max_claude_code_version: string max_claude_code_version: string;
// 分组隔离 // 分组隔离
allow_ungrouped_key_scheduling: boolean allow_ungrouped_key_scheduling: boolean;
// Gateway forwarding behavior // Gateway forwarding behavior
enable_fingerprint_unification: boolean enable_fingerprint_unification: boolean;
enable_metadata_passthrough: boolean enable_metadata_passthrough: boolean;
enable_cch_signing: boolean enable_cch_signing: boolean;
web_search_emulation_enabled?: boolean web_search_emulation_enabled?: boolean;
// Payment configuration // Payment configuration
payment_enabled: boolean payment_enabled: boolean;
payment_min_amount: number payment_min_amount: number;
payment_max_amount: number payment_max_amount: number;
payment_daily_limit: number payment_daily_limit: number;
payment_order_timeout_minutes: number payment_order_timeout_minutes: number;
payment_max_pending_orders: number payment_max_pending_orders: number;
payment_enabled_types: string[] payment_enabled_types: string[];
payment_balance_disabled: boolean payment_balance_disabled: boolean;
payment_balance_recharge_multiplier: number payment_balance_recharge_multiplier: number;
payment_recharge_fee_rate: number payment_recharge_fee_rate: number;
payment_load_balance_strategy: string payment_load_balance_strategy: string;
payment_product_name_prefix: string payment_product_name_prefix: string;
payment_product_name_suffix: string payment_product_name_suffix: string;
payment_help_image_url: string payment_help_image_url: string;
payment_help_text: string payment_help_text: string;
payment_cancel_rate_limit_enabled: boolean payment_cancel_rate_limit_enabled: boolean;
payment_cancel_rate_limit_max: number payment_cancel_rate_limit_max: number;
payment_cancel_rate_limit_window: number payment_cancel_rate_limit_window: number;
payment_cancel_rate_limit_unit: string payment_cancel_rate_limit_unit: string;
payment_cancel_rate_limit_window_mode: string payment_cancel_rate_limit_window_mode: string;
payment_visible_method_alipay_source?: string payment_visible_method_alipay_source?: string;
payment_visible_method_wxpay_source?: string payment_visible_method_wxpay_source?: string;
payment_visible_method_alipay_enabled?: boolean payment_visible_method_alipay_enabled?: boolean;
payment_visible_method_wxpay_enabled?: boolean payment_visible_method_wxpay_enabled?: boolean;
openai_advanced_scheduler_enabled?: boolean openai_advanced_scheduler_enabled?: boolean;
// Balance & quota notification // Balance & quota notification
balance_low_notify_enabled: boolean balance_low_notify_enabled: boolean;
balance_low_notify_threshold: number balance_low_notify_threshold: number;
balance_low_notify_recharge_url: string balance_low_notify_recharge_url: string;
account_quota_notify_enabled: boolean account_quota_notify_enabled: boolean;
account_quota_notify_emails: NotifyEmailEntry[] account_quota_notify_emails: NotifyEmailEntry[];
} }
export interface UpdateSettingsRequest { export interface UpdateSettingsRequest {
registration_enabled?: boolean registration_enabled?: boolean;
email_verify_enabled?: boolean email_verify_enabled?: boolean;
registration_email_suffix_whitelist?: string[] registration_email_suffix_whitelist?: string[];
promo_code_enabled?: boolean promo_code_enabled?: boolean;
password_reset_enabled?: boolean password_reset_enabled?: boolean;
frontend_url?: string frontend_url?: string;
invitation_code_enabled?: boolean invitation_code_enabled?: boolean;
totp_enabled?: boolean // TOTP 双因素认证 totp_enabled?: boolean; // TOTP 双因素认证
default_balance?: number default_balance?: number;
default_concurrency?: number default_concurrency?: number;
default_subscriptions?: DefaultSubscriptionSetting[] default_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_email_balance?: number auth_source_default_email_balance?: number;
auth_source_default_email_concurrency?: number auth_source_default_email_concurrency?: number;
auth_source_default_email_subscriptions?: DefaultSubscriptionSetting[] auth_source_default_email_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_email_grant_on_signup?: boolean auth_source_default_email_grant_on_signup?: boolean;
auth_source_default_email_grant_on_first_bind?: boolean auth_source_default_email_grant_on_first_bind?: boolean;
auth_source_default_linuxdo_balance?: number auth_source_default_linuxdo_balance?: number;
auth_source_default_linuxdo_concurrency?: number auth_source_default_linuxdo_concurrency?: number;
auth_source_default_linuxdo_subscriptions?: DefaultSubscriptionSetting[] auth_source_default_linuxdo_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_linuxdo_grant_on_signup?: boolean auth_source_default_linuxdo_grant_on_signup?: boolean;
auth_source_default_linuxdo_grant_on_first_bind?: boolean auth_source_default_linuxdo_grant_on_first_bind?: boolean;
auth_source_default_oidc_balance?: number auth_source_default_oidc_balance?: number;
auth_source_default_oidc_concurrency?: number auth_source_default_oidc_concurrency?: number;
auth_source_default_oidc_subscriptions?: DefaultSubscriptionSetting[] auth_source_default_oidc_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_oidc_grant_on_signup?: boolean auth_source_default_oidc_grant_on_signup?: boolean;
auth_source_default_oidc_grant_on_first_bind?: boolean auth_source_default_oidc_grant_on_first_bind?: boolean;
auth_source_default_wechat_balance?: number auth_source_default_wechat_balance?: number;
auth_source_default_wechat_concurrency?: number auth_source_default_wechat_concurrency?: number;
auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[] auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[];
auth_source_default_wechat_grant_on_signup?: boolean auth_source_default_wechat_grant_on_signup?: boolean;
auth_source_default_wechat_grant_on_first_bind?: boolean auth_source_default_wechat_grant_on_first_bind?: boolean;
force_email_on_third_party_signup?: boolean force_email_on_third_party_signup?: boolean;
site_name?: string site_name?: string;
site_logo?: string site_logo?: string;
site_subtitle?: string site_subtitle?: string;
api_base_url?: string api_base_url?: string;
contact_info?: string contact_info?: string;
doc_url?: string doc_url?: string;
home_content?: string home_content?: string;
hide_ccs_import_button?: boolean hide_ccs_import_button?: boolean;
table_default_page_size?: number table_default_page_size?: number;
table_page_size_options?: number[] table_page_size_options?: number[];
backend_mode_enabled?: boolean backend_mode_enabled?: boolean;
custom_menu_items?: CustomMenuItem[] custom_menu_items?: CustomMenuItem[];
custom_endpoints?: CustomEndpoint[] custom_endpoints?: CustomEndpoint[];
smtp_host?: string smtp_host?: string;
smtp_port?: number smtp_port?: number;
smtp_username?: string smtp_username?: string;
smtp_password?: string smtp_password?: string;
smtp_from_email?: string smtp_from_email?: string;
smtp_from_name?: string smtp_from_name?: string;
smtp_use_tls?: boolean smtp_use_tls?: boolean;
turnstile_enabled?: boolean turnstile_enabled?: boolean;
turnstile_site_key?: string turnstile_site_key?: string;
turnstile_secret_key?: string turnstile_secret_key?: string;
linuxdo_connect_enabled?: boolean linuxdo_connect_enabled?: boolean;
linuxdo_connect_client_id?: string linuxdo_connect_client_id?: string;
linuxdo_connect_client_secret?: string linuxdo_connect_client_secret?: string;
linuxdo_connect_redirect_url?: string linuxdo_connect_redirect_url?: string;
oidc_connect_enabled?: boolean wechat_connect_enabled?: boolean;
oidc_connect_provider_name?: string wechat_connect_app_id?: string;
oidc_connect_client_id?: string wechat_connect_app_secret?: string;
oidc_connect_client_secret?: string wechat_connect_mode?: string;
oidc_connect_issuer_url?: string wechat_connect_scopes?: string;
oidc_connect_discovery_url?: string wechat_connect_redirect_url?: string;
oidc_connect_authorize_url?: string wechat_connect_frontend_redirect_url?: string;
oidc_connect_token_url?: string oidc_connect_enabled?: boolean;
oidc_connect_userinfo_url?: string oidc_connect_provider_name?: string;
oidc_connect_jwks_url?: string oidc_connect_client_id?: string;
oidc_connect_scopes?: string oidc_connect_client_secret?: string;
oidc_connect_redirect_url?: string oidc_connect_issuer_url?: string;
oidc_connect_frontend_redirect_url?: string oidc_connect_discovery_url?: string;
oidc_connect_token_auth_method?: string oidc_connect_authorize_url?: string;
oidc_connect_use_pkce?: boolean oidc_connect_token_url?: string;
oidc_connect_validate_id_token?: boolean oidc_connect_userinfo_url?: string;
oidc_connect_allowed_signing_algs?: string oidc_connect_jwks_url?: string;
oidc_connect_clock_skew_seconds?: number oidc_connect_scopes?: string;
oidc_connect_require_email_verified?: boolean oidc_connect_redirect_url?: string;
oidc_connect_userinfo_email_path?: string oidc_connect_frontend_redirect_url?: string;
oidc_connect_userinfo_id_path?: string oidc_connect_token_auth_method?: string;
oidc_connect_userinfo_username_path?: string oidc_connect_use_pkce?: boolean;
enable_model_fallback?: boolean oidc_connect_validate_id_token?: boolean;
fallback_model_anthropic?: string oidc_connect_allowed_signing_algs?: string;
fallback_model_openai?: string oidc_connect_clock_skew_seconds?: number;
fallback_model_gemini?: string oidc_connect_require_email_verified?: boolean;
fallback_model_antigravity?: string oidc_connect_userinfo_email_path?: string;
enable_identity_patch?: boolean oidc_connect_userinfo_id_path?: string;
identity_patch_prompt?: string oidc_connect_userinfo_username_path?: string;
ops_monitoring_enabled?: boolean enable_model_fallback?: boolean;
ops_realtime_monitoring_enabled?: boolean fallback_model_anthropic?: string;
ops_query_mode_default?: 'auto' | 'raw' | 'preagg' | string fallback_model_openai?: string;
ops_metrics_interval_seconds?: number fallback_model_gemini?: string;
min_claude_code_version?: string fallback_model_antigravity?: string;
max_claude_code_version?: string enable_identity_patch?: boolean;
allow_ungrouped_key_scheduling?: boolean identity_patch_prompt?: string;
enable_fingerprint_unification?: boolean ops_monitoring_enabled?: boolean;
enable_metadata_passthrough?: boolean ops_realtime_monitoring_enabled?: boolean;
enable_cch_signing?: boolean ops_query_mode_default?: "auto" | "raw" | "preagg" | string;
ops_metrics_interval_seconds?: number;
min_claude_code_version?: string;
max_claude_code_version?: string;
allow_ungrouped_key_scheduling?: boolean;
enable_fingerprint_unification?: boolean;
enable_metadata_passthrough?: boolean;
enable_cch_signing?: boolean;
// Payment configuration // Payment configuration
payment_enabled?: boolean payment_enabled?: boolean;
payment_min_amount?: number payment_min_amount?: number;
payment_max_amount?: number payment_max_amount?: number;
payment_daily_limit?: number payment_daily_limit?: number;
payment_order_timeout_minutes?: number payment_order_timeout_minutes?: number;
payment_max_pending_orders?: number payment_max_pending_orders?: number;
payment_enabled_types?: string[] payment_enabled_types?: string[];
payment_balance_disabled?: boolean payment_balance_disabled?: boolean;
payment_balance_recharge_multiplier?: number payment_balance_recharge_multiplier?: number;
payment_recharge_fee_rate?: number payment_recharge_fee_rate?: number;
payment_load_balance_strategy?: string payment_load_balance_strategy?: string;
payment_product_name_prefix?: string payment_product_name_prefix?: string;
payment_product_name_suffix?: string payment_product_name_suffix?: string;
payment_help_image_url?: string payment_help_image_url?: string;
payment_help_text?: string payment_help_text?: string;
payment_cancel_rate_limit_enabled?: boolean payment_cancel_rate_limit_enabled?: boolean;
payment_cancel_rate_limit_max?: number payment_cancel_rate_limit_max?: number;
payment_cancel_rate_limit_window?: number payment_cancel_rate_limit_window?: number;
payment_cancel_rate_limit_unit?: string payment_cancel_rate_limit_unit?: string;
payment_cancel_rate_limit_window_mode?: string payment_cancel_rate_limit_window_mode?: string;
payment_visible_method_alipay_source?: string payment_visible_method_alipay_source?: string;
payment_visible_method_wxpay_source?: string payment_visible_method_wxpay_source?: string;
payment_visible_method_alipay_enabled?: boolean payment_visible_method_alipay_enabled?: boolean;
payment_visible_method_wxpay_enabled?: boolean payment_visible_method_wxpay_enabled?: boolean;
openai_advanced_scheduler_enabled?: boolean openai_advanced_scheduler_enabled?: boolean;
// Balance & quota notification // Balance & quota notification
balance_low_notify_enabled?: boolean balance_low_notify_enabled?: boolean;
balance_low_notify_threshold?: number balance_low_notify_threshold?: number;
balance_low_notify_recharge_url?: string balance_low_notify_recharge_url?: string;
account_quota_notify_enabled?: boolean account_quota_notify_enabled?: boolean;
account_quota_notify_emails?: NotifyEmailEntry[] account_quota_notify_emails?: NotifyEmailEntry[];
} }
/** /**
...@@ -453,8 +554,8 @@ export interface UpdateSettingsRequest { ...@@ -453,8 +554,8 @@ export interface UpdateSettingsRequest {
* @returns System settings * @returns System settings
*/ */
export async function getSettings(): Promise<SystemSettings> { export async function getSettings(): Promise<SystemSettings> {
const { data } = await apiClient.get<SystemSettings>('/admin/settings') const { data } = await apiClient.get<SystemSettings>("/admin/settings");
return data return data;
} }
/** /**
...@@ -462,20 +563,25 @@ export async function getSettings(): Promise<SystemSettings> { ...@@ -462,20 +563,25 @@ export async function getSettings(): Promise<SystemSettings> {
* @param settings - Partial settings to update * @param settings - Partial settings to update
* @returns Updated settings * @returns Updated settings
*/ */
export async function updateSettings(settings: UpdateSettingsRequest): Promise<SystemSettings> { export async function updateSettings(
const { data } = await apiClient.put<SystemSettings>('/admin/settings', settings) settings: UpdateSettingsRequest,
return data ): Promise<SystemSettings> {
const { data } = await apiClient.put<SystemSettings>(
"/admin/settings",
settings,
);
return data;
} }
/** /**
* Test SMTP connection request * Test SMTP connection request
*/ */
export interface TestSmtpRequest { export interface TestSmtpRequest {
smtp_host: string smtp_host: string;
smtp_port: number smtp_port: number;
smtp_username: string smtp_username: string;
smtp_password: string smtp_password: string;
smtp_use_tls: boolean smtp_use_tls: boolean;
} }
/** /**
...@@ -483,23 +589,28 @@ export interface TestSmtpRequest { ...@@ -483,23 +589,28 @@ export interface TestSmtpRequest {
* @param config - SMTP configuration to test * @param config - SMTP configuration to test
* @returns Test result message * @returns Test result message
*/ */
export async function testSmtpConnection(config: TestSmtpRequest): Promise<{ message: string }> { export async function testSmtpConnection(
const { data } = await apiClient.post<{ message: string }>('/admin/settings/test-smtp', config) config: TestSmtpRequest,
return data ): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>(
"/admin/settings/test-smtp",
config,
);
return data;
} }
/** /**
* Send test email request * Send test email request
*/ */
export interface SendTestEmailRequest { export interface SendTestEmailRequest {
email: string email: string;
smtp_host: string smtp_host: string;
smtp_port: number smtp_port: number;
smtp_username: string smtp_username: string;
smtp_password: string smtp_password: string;
smtp_from_email: string smtp_from_email: string;
smtp_from_name: string smtp_from_name: string;
smtp_use_tls: boolean smtp_use_tls: boolean;
} }
/** /**
...@@ -507,20 +618,22 @@ export interface SendTestEmailRequest { ...@@ -507,20 +618,22 @@ export interface SendTestEmailRequest {
* @param request - Email address and SMTP config * @param request - Email address and SMTP config
* @returns Test result message * @returns Test result message
*/ */
export async function sendTestEmail(request: SendTestEmailRequest): Promise<{ message: string }> { export async function sendTestEmail(
request: SendTestEmailRequest,
): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>( const { data } = await apiClient.post<{ message: string }>(
'/admin/settings/send-test-email', "/admin/settings/send-test-email",
request request,
) );
return data return data;
} }
/** /**
* Admin API Key status response * Admin API Key status response
*/ */
export interface AdminApiKeyStatus { export interface AdminApiKeyStatus {
exists: boolean exists: boolean;
masked_key: string masked_key: string;
} }
/** /**
...@@ -528,8 +641,10 @@ export interface AdminApiKeyStatus { ...@@ -528,8 +641,10 @@ export interface AdminApiKeyStatus {
* @returns Status indicating if key exists and masked version * @returns Status indicating if key exists and masked version
*/ */
export async function getAdminApiKey(): Promise<AdminApiKeyStatus> { export async function getAdminApiKey(): Promise<AdminApiKeyStatus> {
const { data } = await apiClient.get<AdminApiKeyStatus>('/admin/settings/admin-api-key') const { data } = await apiClient.get<AdminApiKeyStatus>(
return data "/admin/settings/admin-api-key",
);
return data;
} }
/** /**
...@@ -537,8 +652,10 @@ export async function getAdminApiKey(): Promise<AdminApiKeyStatus> { ...@@ -537,8 +652,10 @@ export async function getAdminApiKey(): Promise<AdminApiKeyStatus> {
* @returns The new full API key (only shown once) * @returns The new full API key (only shown once)
*/ */
export async function regenerateAdminApiKey(): Promise<{ key: string }> { export async function regenerateAdminApiKey(): Promise<{ key: string }> {
const { data } = await apiClient.post<{ key: string }>('/admin/settings/admin-api-key/regenerate') const { data } = await apiClient.post<{ key: string }>(
return data "/admin/settings/admin-api-key/regenerate",
);
return data;
} }
/** /**
...@@ -546,8 +663,10 @@ export async function regenerateAdminApiKey(): Promise<{ key: string }> { ...@@ -546,8 +663,10 @@ export async function regenerateAdminApiKey(): Promise<{ key: string }> {
* @returns Success message * @returns Success message
*/ */
export async function deleteAdminApiKey(): Promise<{ message: string }> { export async function deleteAdminApiKey(): Promise<{ message: string }> {
const { data } = await apiClient.delete<{ message: string }>('/admin/settings/admin-api-key') const { data } = await apiClient.delete<{ message: string }>(
return data "/admin/settings/admin-api-key",
);
return data;
} }
// ==================== Overload Cooldown Settings ==================== // ==================== Overload Cooldown Settings ====================
...@@ -556,23 +675,25 @@ export async function deleteAdminApiKey(): Promise<{ message: string }> { ...@@ -556,23 +675,25 @@ export async function deleteAdminApiKey(): Promise<{ message: string }> {
* Overload cooldown settings interface (529 handling) * Overload cooldown settings interface (529 handling)
*/ */
export interface OverloadCooldownSettings { export interface OverloadCooldownSettings {
enabled: boolean enabled: boolean;
cooldown_minutes: number cooldown_minutes: number;
} }
export async function getOverloadCooldownSettings(): Promise<OverloadCooldownSettings> { export async function getOverloadCooldownSettings(): Promise<OverloadCooldownSettings> {
const { data } = await apiClient.get<OverloadCooldownSettings>('/admin/settings/overload-cooldown') const { data } = await apiClient.get<OverloadCooldownSettings>(
return data "/admin/settings/overload-cooldown",
);
return data;
} }
export async function updateOverloadCooldownSettings( export async function updateOverloadCooldownSettings(
settings: OverloadCooldownSettings settings: OverloadCooldownSettings,
): Promise<OverloadCooldownSettings> { ): Promise<OverloadCooldownSettings> {
const { data } = await apiClient.put<OverloadCooldownSettings>( const { data } = await apiClient.put<OverloadCooldownSettings>(
'/admin/settings/overload-cooldown', "/admin/settings/overload-cooldown",
settings settings,
) );
return data return data;
} }
// ==================== Stream Timeout Settings ==================== // ==================== Stream Timeout Settings ====================
...@@ -581,11 +702,11 @@ export async function updateOverloadCooldownSettings( ...@@ -581,11 +702,11 @@ export async function updateOverloadCooldownSettings(
* Stream timeout settings interface * Stream timeout settings interface
*/ */
export interface StreamTimeoutSettings { export interface StreamTimeoutSettings {
enabled: boolean enabled: boolean;
action: 'temp_unsched' | 'error' | 'none' action: "temp_unsched" | "error" | "none";
temp_unsched_minutes: number temp_unsched_minutes: number;
threshold_count: number threshold_count: number;
threshold_window_minutes: number threshold_window_minutes: number;
} }
/** /**
...@@ -593,8 +714,10 @@ export interface StreamTimeoutSettings { ...@@ -593,8 +714,10 @@ export interface StreamTimeoutSettings {
* @returns Stream timeout settings * @returns Stream timeout settings
*/ */
export async function getStreamTimeoutSettings(): Promise<StreamTimeoutSettings> { export async function getStreamTimeoutSettings(): Promise<StreamTimeoutSettings> {
const { data } = await apiClient.get<StreamTimeoutSettings>('/admin/settings/stream-timeout') const { data } = await apiClient.get<StreamTimeoutSettings>(
return data "/admin/settings/stream-timeout",
);
return data;
} }
/** /**
...@@ -603,13 +726,13 @@ export async function getStreamTimeoutSettings(): Promise<StreamTimeoutSettings> ...@@ -603,13 +726,13 @@ export async function getStreamTimeoutSettings(): Promise<StreamTimeoutSettings>
* @returns Updated settings * @returns Updated settings
*/ */
export async function updateStreamTimeoutSettings( export async function updateStreamTimeoutSettings(
settings: StreamTimeoutSettings settings: StreamTimeoutSettings,
): Promise<StreamTimeoutSettings> { ): Promise<StreamTimeoutSettings> {
const { data } = await apiClient.put<StreamTimeoutSettings>( const { data } = await apiClient.put<StreamTimeoutSettings>(
'/admin/settings/stream-timeout', "/admin/settings/stream-timeout",
settings settings,
) );
return data return data;
} }
// ==================== Rectifier Settings ==================== // ==================== Rectifier Settings ====================
...@@ -618,11 +741,11 @@ export async function updateStreamTimeoutSettings( ...@@ -618,11 +741,11 @@ export async function updateStreamTimeoutSettings(
* Rectifier settings interface * Rectifier settings interface
*/ */
export interface RectifierSettings { export interface RectifierSettings {
enabled: boolean enabled: boolean;
thinking_signature_enabled: boolean thinking_signature_enabled: boolean;
thinking_budget_enabled: boolean thinking_budget_enabled: boolean;
apikey_signature_enabled: boolean apikey_signature_enabled: boolean;
apikey_signature_patterns: string[] apikey_signature_patterns: string[];
} }
/** /**
...@@ -630,8 +753,10 @@ export interface RectifierSettings { ...@@ -630,8 +753,10 @@ export interface RectifierSettings {
* @returns Rectifier settings * @returns Rectifier settings
*/ */
export async function getRectifierSettings(): Promise<RectifierSettings> { export async function getRectifierSettings(): Promise<RectifierSettings> {
const { data } = await apiClient.get<RectifierSettings>('/admin/settings/rectifier') const { data } = await apiClient.get<RectifierSettings>(
return data "/admin/settings/rectifier",
);
return data;
} }
/** /**
...@@ -640,13 +765,13 @@ export async function getRectifierSettings(): Promise<RectifierSettings> { ...@@ -640,13 +765,13 @@ export async function getRectifierSettings(): Promise<RectifierSettings> {
* @returns Updated settings * @returns Updated settings
*/ */
export async function updateRectifierSettings( export async function updateRectifierSettings(
settings: RectifierSettings settings: RectifierSettings,
): Promise<RectifierSettings> { ): Promise<RectifierSettings> {
const { data } = await apiClient.put<RectifierSettings>( const { data } = await apiClient.put<RectifierSettings>(
'/admin/settings/rectifier', "/admin/settings/rectifier",
settings settings,
) );
return data return data;
} }
// ==================== Beta Policy Settings ==================== // ==================== Beta Policy Settings ====================
...@@ -655,20 +780,20 @@ export async function updateRectifierSettings( ...@@ -655,20 +780,20 @@ export async function updateRectifierSettings(
* Beta policy rule interface * Beta policy rule interface
*/ */
export interface BetaPolicyRule { export interface BetaPolicyRule {
beta_token: string beta_token: string;
action: 'pass' | 'filter' | 'block' action: "pass" | "filter" | "block";
scope: 'all' | 'oauth' | 'apikey' | 'bedrock' scope: "all" | "oauth" | "apikey" | "bedrock";
error_message?: string error_message?: string;
model_whitelist?: string[] model_whitelist?: string[];
fallback_action?: 'pass' | 'filter' | 'block' fallback_action?: "pass" | "filter" | "block";
fallback_error_message?: string fallback_error_message?: string;
} }
/** /**
* Beta policy settings interface * Beta policy settings interface
*/ */
export interface BetaPolicySettings { export interface BetaPolicySettings {
rules: BetaPolicyRule[] rules: BetaPolicyRule[];
} }
/** /**
...@@ -676,8 +801,10 @@ export interface BetaPolicySettings { ...@@ -676,8 +801,10 @@ export interface BetaPolicySettings {
* @returns Beta policy settings * @returns Beta policy settings
*/ */
export async function getBetaPolicySettings(): Promise<BetaPolicySettings> { export async function getBetaPolicySettings(): Promise<BetaPolicySettings> {
const { data } = await apiClient.get<BetaPolicySettings>('/admin/settings/beta-policy') const { data } = await apiClient.get<BetaPolicySettings>(
return data "/admin/settings/beta-policy",
);
return data;
} }
/** /**
...@@ -686,70 +813,73 @@ export async function getBetaPolicySettings(): Promise<BetaPolicySettings> { ...@@ -686,70 +813,73 @@ export async function getBetaPolicySettings(): Promise<BetaPolicySettings> {
* @returns Updated settings * @returns Updated settings
*/ */
export async function updateBetaPolicySettings( export async function updateBetaPolicySettings(
settings: BetaPolicySettings settings: BetaPolicySettings,
): Promise<BetaPolicySettings> { ): Promise<BetaPolicySettings> {
const { data } = await apiClient.put<BetaPolicySettings>( const { data } = await apiClient.put<BetaPolicySettings>(
'/admin/settings/beta-policy', "/admin/settings/beta-policy",
settings settings,
) );
return data return data;
} }
// --- Web Search Emulation Config --- // --- Web Search Emulation Config ---
export interface WebSearchProviderConfig { export interface WebSearchProviderConfig {
type: 'brave' | 'tavily' type: "brave" | "tavily";
api_key: string api_key: string;
api_key_configured: boolean api_key_configured: boolean;
quota_limit: number | null quota_limit: number | null;
subscribed_at: number | null subscribed_at: number | null;
quota_used?: number quota_used?: number;
proxy_id: number | null proxy_id: number | null;
expires_at: number | null expires_at: number | null;
} }
export interface WebSearchEmulationConfig { export interface WebSearchEmulationConfig {
enabled: boolean enabled: boolean;
providers: WebSearchProviderConfig[] providers: WebSearchProviderConfig[];
} }
export interface WebSearchTestResult { export interface WebSearchTestResult {
provider: string provider: string;
results: { url: string; title: string; snippet: string; page_age?: string }[] results: { url: string; title: string; snippet: string; page_age?: string }[];
query: string query: string;
} }
export async function getWebSearchEmulationConfig(): Promise<WebSearchEmulationConfig> { export async function getWebSearchEmulationConfig(): Promise<WebSearchEmulationConfig> {
const { data } = await apiClient.get<WebSearchEmulationConfig>( const { data } = await apiClient.get<WebSearchEmulationConfig>(
'/admin/settings/web-search-emulation' "/admin/settings/web-search-emulation",
) );
return data return data;
} }
export async function updateWebSearchEmulationConfig( export async function updateWebSearchEmulationConfig(
config: WebSearchEmulationConfig config: WebSearchEmulationConfig,
): Promise<WebSearchEmulationConfig> { ): Promise<WebSearchEmulationConfig> {
const { data } = await apiClient.put<WebSearchEmulationConfig>( const { data } = await apiClient.put<WebSearchEmulationConfig>(
'/admin/settings/web-search-emulation', "/admin/settings/web-search-emulation",
config config,
) );
return data return data;
} }
export async function testWebSearchEmulation( export async function testWebSearchEmulation(
query: string query: string,
): Promise<WebSearchTestResult> { ): Promise<WebSearchTestResult> {
const { data } = await apiClient.post<WebSearchTestResult>( const { data } = await apiClient.post<WebSearchTestResult>(
'/admin/settings/web-search-emulation/test', "/admin/settings/web-search-emulation/test",
{ query } { query },
) );
return data return data;
} }
export async function resetWebSearchUsage( export async function resetWebSearchUsage(payload: {
payload: { provider_type: string } provider_type: string;
): Promise<void> { }): Promise<void> {
await apiClient.post('/admin/settings/web-search-emulation/reset-usage', payload) await apiClient.post(
"/admin/settings/web-search-emulation/reset-usage",
payload,
);
} }
export const settingsAPI = { export const settingsAPI = {
...@@ -771,7 +901,7 @@ export const settingsAPI = { ...@@ -771,7 +901,7 @@ export const settingsAPI = {
getWebSearchEmulationConfig, getWebSearchEmulationConfig,
updateWebSearchEmulationConfig, updateWebSearchEmulationConfig,
testWebSearchEmulation, testWebSearchEmulation,
resetWebSearchUsage resetWebSearchUsage,
} };
export default settingsAPI export default settingsAPI;
This source diff could not be displayed because it is too large. You can view the blob instead.
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, h, ref } from 'vue' import { defineComponent, h, ref } from "vue";
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from "@vue/test-utils";
import SettingsView from '../SettingsView.vue' import SettingsView from "../SettingsView.vue";
const { const {
getSettings, getSettings,
...@@ -38,9 +38,9 @@ const { ...@@ -38,9 +38,9 @@ const {
adminSettingsFetch: vi.fn(), adminSettingsFetch: vi.fn(),
showError: vi.fn(), showError: vi.fn(),
showSuccess: vi.fn(), showSuccess: vi.fn(),
})) }));
vi.mock('@/api', () => ({ vi.mock("@/api", () => ({
adminAPI: { adminAPI: {
settings: { settings: {
getSettings, getSettings,
...@@ -63,9 +63,9 @@ vi.mock('@/api', () => ({ ...@@ -63,9 +63,9 @@ vi.mock('@/api', () => ({
getProviders, getProviders,
}, },
}, },
})) }));
vi.mock('@/stores', () => ({ vi.mock("@/stores", () => ({
useAppStore: () => ({ useAppStore: () => ({
showError, showError,
showSuccess, showSuccess,
...@@ -73,36 +73,36 @@ vi.mock('@/stores', () => ({ ...@@ -73,36 +73,36 @@ vi.mock('@/stores', () => ({
showInfo: vi.fn(), showInfo: vi.fn(),
fetchPublicSettings, fetchPublicSettings,
}), }),
})) }));
vi.mock('@/stores/adminSettings', () => ({ vi.mock("@/stores/adminSettings", () => ({
useAdminSettingsStore: () => ({ useAdminSettingsStore: () => ({
fetch: adminSettingsFetch, fetch: adminSettingsFetch,
}), }),
})) }));
vi.mock('@/composables/useClipboard', () => ({ vi.mock("@/composables/useClipboard", () => ({
useClipboard: () => ({ useClipboard: () => ({
copyToClipboard: vi.fn(), copyToClipboard: vi.fn(),
}), }),
})) }));
vi.mock('@/utils/apiError', () => ({ vi.mock("@/utils/apiError", () => ({
extractApiErrorMessage: () => 'error', extractApiErrorMessage: () => "error",
})) }));
vi.mock('vue-i18n', async () => { vi.mock("vue-i18n", async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n') const actual = await vi.importActual<typeof import("vue-i18n")>("vue-i18n");
return { return {
...actual, ...actual,
useI18n: () => ({ useI18n: () => ({
t: (key: string) => key, t: (key: string) => key,
locale: ref('zh-CN'), locale: ref("zh-CN"),
}), }),
} };
}) });
const AppLayoutStub = { template: '<div><slot /></div>' } const AppLayoutStub = { template: "<div><slot /></div>" };
const ToggleStub = defineComponent({ const ToggleStub = defineComponent({
props: { props: {
modelValue: { modelValue: {
...@@ -110,25 +110,25 @@ const ToggleStub = defineComponent({ ...@@ -110,25 +110,25 @@ const ToggleStub = defineComponent({
default: false, default: false,
}, },
}, },
emits: ['update:modelValue'], emits: ["update:modelValue"],
setup(props, { emit }) { setup(props, { emit }) {
return () => return () =>
h('input', { h("input", {
class: 'toggle-stub', class: "toggle-stub",
type: 'checkbox', type: "checkbox",
checked: props.modelValue, checked: props.modelValue,
onChange: (event: Event) => { onChange: (event: Event) => {
emit('update:modelValue', (event.target as HTMLInputElement).checked) emit("update:modelValue", (event.target as HTMLInputElement).checked);
}, },
}) });
}, },
}) });
const SelectStub = defineComponent({ const SelectStub = defineComponent({
props: { props: {
modelValue: { modelValue: {
type: [String, Number, Boolean, null], type: [String, Number, Boolean, null],
default: '', default: "",
}, },
options: { options: {
type: Array, type: Array,
...@@ -136,42 +136,43 @@ const SelectStub = defineComponent({ ...@@ -136,42 +136,43 @@ const SelectStub = defineComponent({
}, },
placeholder: { placeholder: {
type: String, type: String,
default: '', default: "",
}, },
}, },
emits: ['update:modelValue', 'change'], emits: ["update:modelValue", "change"],
setup(props, { emit }) { setup(props, { emit }) {
const onChange = (event: Event) => { const onChange = (event: Event) => {
const target = event.target as HTMLSelectElement const target = event.target as HTMLSelectElement;
emit('update:modelValue', target.value) emit("update:modelValue", target.value);
const option = (props.options as Array<Record<string, unknown>>).find( const option =
(item) => String(item.value ?? '') === target.value (props.options as Array<Record<string, unknown>>).find(
) ?? null (item) => String(item.value ?? "") === target.value,
emit('change', target.value, option) ) ?? null;
} emit("change", target.value, option);
};
return () => return () =>
h( h(
'select', "select",
{ {
class: 'select-stub', class: "select-stub",
value: props.modelValue ?? '', value: props.modelValue ?? "",
'data-placeholder': props.placeholder, "data-placeholder": props.placeholder,
onChange, onChange,
}, },
(props.options as Array<Record<string, unknown>>).map((option) => (props.options as Array<Record<string, unknown>>).map((option) =>
h( h(
'option', "option",
{ {
key: `${String(option.value ?? '')}:${String(option.label ?? '')}`, key: `${String(option.value ?? "")}:${String(option.label ?? "")}`,
value: option.value as string, value: option.value as string,
}, },
String(option.label ?? '') String(option.label ?? ""),
) ),
) ),
) );
}, },
}) });
const baseSettingsResponse = { const baseSettingsResponse = {
registration_enabled: true, registration_enabled: true,
...@@ -185,69 +186,77 @@ const baseSettingsResponse = { ...@@ -185,69 +186,77 @@ const baseSettingsResponse = {
default_balance: 0, default_balance: 0,
default_concurrency: 1, default_concurrency: 1,
default_subscriptions: [], default_subscriptions: [],
site_name: 'Sub2API', site_name: "Sub2API",
site_logo: '', site_logo: "",
site_subtitle: '', site_subtitle: "",
api_base_url: '', api_base_url: "",
contact_info: '', contact_info: "",
doc_url: '', doc_url: "",
home_content: '', home_content: "",
hide_ccs_import_button: false, hide_ccs_import_button: false,
table_default_page_size: 20, table_default_page_size: 20,
table_page_size_options: [10, 20, 50, 100], table_page_size_options: [10, 20, 50, 100],
backend_mode_enabled: false, backend_mode_enabled: false,
custom_menu_items: [], custom_menu_items: [],
custom_endpoints: [], custom_endpoints: [],
frontend_url: '', frontend_url: "",
smtp_host: '', smtp_host: "",
smtp_port: 587, smtp_port: 587,
smtp_username: '', smtp_username: "",
smtp_password_configured: false, smtp_password_configured: false,
smtp_from_email: '', smtp_from_email: "",
smtp_from_name: '', smtp_from_name: "",
smtp_use_tls: true, smtp_use_tls: true,
turnstile_enabled: false, turnstile_enabled: false,
turnstile_site_key: '', turnstile_site_key: "",
turnstile_secret_key_configured: false, turnstile_secret_key_configured: false,
linuxdo_connect_enabled: false, linuxdo_connect_enabled: false,
linuxdo_connect_client_id: '', linuxdo_connect_client_id: "",
linuxdo_connect_client_secret_configured: false, linuxdo_connect_client_secret_configured: false,
linuxdo_connect_redirect_url: '', linuxdo_connect_redirect_url: "",
wechat_connect_enabled: true,
wechat_connect_app_id: "wx-app-id-123",
wechat_connect_app_secret_configured: true,
wechat_connect_mode: "mp",
wechat_connect_scopes: "",
wechat_connect_redirect_url:
"https://admin.example.com/api/v1/auth/oauth/wechat/callback",
wechat_connect_frontend_redirect_url: "/auth/wechat/callback",
oidc_connect_enabled: false, oidc_connect_enabled: false,
oidc_connect_provider_name: 'OIDC', oidc_connect_provider_name: "OIDC",
oidc_connect_client_id: '', oidc_connect_client_id: "",
oidc_connect_client_secret_configured: false, oidc_connect_client_secret_configured: false,
oidc_connect_issuer_url: '', oidc_connect_issuer_url: "",
oidc_connect_discovery_url: '', oidc_connect_discovery_url: "",
oidc_connect_authorize_url: '', oidc_connect_authorize_url: "",
oidc_connect_token_url: '', oidc_connect_token_url: "",
oidc_connect_userinfo_url: '', oidc_connect_userinfo_url: "",
oidc_connect_jwks_url: '', oidc_connect_jwks_url: "",
oidc_connect_scopes: 'openid email profile', oidc_connect_scopes: "openid email profile",
oidc_connect_redirect_url: '', oidc_connect_redirect_url: "",
oidc_connect_frontend_redirect_url: '/auth/oidc/callback', oidc_connect_frontend_redirect_url: "/auth/oidc/callback",
oidc_connect_token_auth_method: 'client_secret_post', oidc_connect_token_auth_method: "client_secret_post",
oidc_connect_use_pkce: true, oidc_connect_use_pkce: true,
oidc_connect_validate_id_token: true, oidc_connect_validate_id_token: true,
oidc_connect_allowed_signing_algs: 'RS256,ES256,PS256', oidc_connect_allowed_signing_algs: "RS256,ES256,PS256",
oidc_connect_clock_skew_seconds: 120, oidc_connect_clock_skew_seconds: 120,
oidc_connect_require_email_verified: false, oidc_connect_require_email_verified: false,
oidc_connect_userinfo_email_path: '', oidc_connect_userinfo_email_path: "",
oidc_connect_userinfo_id_path: '', oidc_connect_userinfo_id_path: "",
oidc_connect_userinfo_username_path: '', oidc_connect_userinfo_username_path: "",
enable_model_fallback: false, enable_model_fallback: false,
fallback_model_anthropic: '', fallback_model_anthropic: "",
fallback_model_openai: '', fallback_model_openai: "",
fallback_model_gemini: '', fallback_model_gemini: "",
fallback_model_antigravity: '', fallback_model_antigravity: "",
enable_identity_patch: false, enable_identity_patch: false,
identity_patch_prompt: '', identity_patch_prompt: "",
ops_monitoring_enabled: false, ops_monitoring_enabled: false,
ops_realtime_monitoring_enabled: false, ops_realtime_monitoring_enabled: false,
ops_query_mode_default: 'auto', ops_query_mode_default: "auto",
ops_metrics_interval_seconds: 60, ops_metrics_interval_seconds: 60,
min_claude_code_version: '', min_claude_code_version: "",
max_claude_code_version: '', max_claude_code_version: "",
allow_ungrouped_key_scheduling: false, allow_ungrouped_key_scheduling: false,
enable_fingerprint_unification: true, enable_fingerprint_unification: true,
enable_metadata_passthrough: false, enable_metadata_passthrough: false,
...@@ -262,27 +271,27 @@ const baseSettingsResponse = { ...@@ -262,27 +271,27 @@ const baseSettingsResponse = {
payment_balance_disabled: false, payment_balance_disabled: false,
payment_balance_recharge_multiplier: 1, payment_balance_recharge_multiplier: 1,
payment_recharge_fee_rate: 0, payment_recharge_fee_rate: 0,
payment_load_balance_strategy: 'round-robin', payment_load_balance_strategy: "round-robin",
payment_product_name_prefix: '', payment_product_name_prefix: "",
payment_product_name_suffix: '', payment_product_name_suffix: "",
payment_help_image_url: '', payment_help_image_url: "",
payment_help_text: '', payment_help_text: "",
payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_enabled: false,
payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_max: 10,
payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_window: 1,
payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_unit: "day",
payment_cancel_rate_limit_window_mode: 'rolling', payment_cancel_rate_limit_window_mode: "rolling",
payment_visible_method_alipay_source: 'alipay_direct', payment_visible_method_alipay_source: "alipay_direct",
payment_visible_method_wxpay_source: 'invalid-source', payment_visible_method_wxpay_source: "invalid-source",
payment_visible_method_alipay_enabled: true, payment_visible_method_alipay_enabled: true,
payment_visible_method_wxpay_enabled: true, payment_visible_method_wxpay_enabled: true,
openai_advanced_scheduler_enabled: false, openai_advanced_scheduler_enabled: false,
balance_low_notify_enabled: false, balance_low_notify_enabled: false,
balance_low_notify_threshold: 0, balance_low_notify_threshold: 0,
balance_low_notify_recharge_url: '', balance_low_notify_recharge_url: "",
account_quota_notify_enabled: false, account_quota_notify_enabled: false,
account_quota_notify_emails: [], account_quota_notify_emails: [],
} };
function mountView() { function mountView() {
return mount(SettingsView, { return mount(SettingsView, {
...@@ -302,184 +311,361 @@ function mountView() { ...@@ -302,184 +311,361 @@ function mountView() {
BackupSettings: true, BackupSettings: true,
}, },
}, },
}) });
} }
async function openPaymentTab(wrapper: ReturnType<typeof mountView>) { async function openPaymentTab(wrapper: ReturnType<typeof mountView>) {
const paymentTabButton = wrapper const paymentTabButton = wrapper
.findAll('button') .findAll("button")
.find((node) => node.text().includes('admin.settings.tabs.payment')) .find((node) => node.text().includes("admin.settings.tabs.payment"));
expect(paymentTabButton).toBeDefined();
await paymentTabButton?.trigger("click");
await flushPromises();
}
async function openSecurityTab(wrapper: ReturnType<typeof mountView>) {
const securityTabButton = wrapper
.findAll("button")
.find((node) => node.text().includes("admin.settings.tabs.security"));
expect(paymentTabButton).toBeDefined() expect(securityTabButton).toBeDefined();
await paymentTabButton?.trigger('click') await securityTabButton?.trigger("click");
await flushPromises() await flushPromises();
} }
describe('admin SettingsView payment visible method controls', () => { describe("admin SettingsView payment visible method controls", () => {
beforeEach(() => { beforeEach(() => {
getSettings.mockReset() getSettings.mockReset();
updateSettings.mockReset() updateSettings.mockReset();
getWebSearchEmulationConfig.mockReset() getWebSearchEmulationConfig.mockReset();
updateWebSearchEmulationConfig.mockReset() updateWebSearchEmulationConfig.mockReset();
getAdminApiKey.mockReset() getAdminApiKey.mockReset();
getOverloadCooldownSettings.mockReset() getOverloadCooldownSettings.mockReset();
getStreamTimeoutSettings.mockReset() getStreamTimeoutSettings.mockReset();
getRectifierSettings.mockReset() getRectifierSettings.mockReset();
getBetaPolicySettings.mockReset() getBetaPolicySettings.mockReset();
getGroups.mockReset() getGroups.mockReset();
listProxies.mockReset() listProxies.mockReset();
getProviders.mockReset() getProviders.mockReset();
fetchPublicSettings.mockReset() fetchPublicSettings.mockReset();
adminSettingsFetch.mockReset() adminSettingsFetch.mockReset();
showError.mockReset() showError.mockReset();
showSuccess.mockReset() showSuccess.mockReset();
getSettings.mockResolvedValue({ ...baseSettingsResponse }) getSettings.mockResolvedValue({ ...baseSettingsResponse });
updateSettings.mockImplementation(async (payload) => ({ updateSettings.mockImplementation(async (payload) => ({
...baseSettingsResponse, ...baseSettingsResponse,
...payload, ...payload,
})) }));
getWebSearchEmulationConfig.mockResolvedValue({ getWebSearchEmulationConfig.mockResolvedValue({
enabled: false, enabled: false,
providers: [], providers: [],
}) });
updateWebSearchEmulationConfig.mockResolvedValue({ updateWebSearchEmulationConfig.mockResolvedValue({
enabled: false, enabled: false,
providers: [], providers: [],
}) });
getAdminApiKey.mockResolvedValue({ getAdminApiKey.mockResolvedValue({
exists: false, exists: false,
masked_key: '', masked_key: "",
}) });
getOverloadCooldownSettings.mockResolvedValue({ getOverloadCooldownSettings.mockResolvedValue({
enabled: true, enabled: true,
cooldown_minutes: 10, cooldown_minutes: 10,
}) });
getStreamTimeoutSettings.mockResolvedValue({ getStreamTimeoutSettings.mockResolvedValue({
enabled: true, enabled: true,
action: 'temp_unsched', action: "temp_unsched",
temp_unsched_minutes: 5, temp_unsched_minutes: 5,
threshold_count: 3, threshold_count: 3,
threshold_window_minutes: 10, threshold_window_minutes: 10,
}) });
getRectifierSettings.mockResolvedValue({ getRectifierSettings.mockResolvedValue({
enabled: true, enabled: true,
thinking_signature_enabled: true, thinking_signature_enabled: true,
thinking_budget_enabled: true, thinking_budget_enabled: true,
apikey_signature_enabled: false, apikey_signature_enabled: false,
apikey_signature_patterns: [], apikey_signature_patterns: [],
}) });
getBetaPolicySettings.mockResolvedValue({ getBetaPolicySettings.mockResolvedValue({
rules: [], rules: [],
}) });
getGroups.mockResolvedValue([]) getGroups.mockResolvedValue([]);
listProxies.mockResolvedValue({ listProxies.mockResolvedValue({
items: [], items: [],
}) });
getProviders.mockResolvedValue({ getProviders.mockResolvedValue({
data: [], data: [],
}) });
fetchPublicSettings.mockResolvedValue(undefined) fetchPublicSettings.mockResolvedValue(undefined);
adminSettingsFetch.mockResolvedValue(undefined) adminSettingsFetch.mockResolvedValue(undefined);
}) });
it('loads canonical source options and normalizes existing values', async () => { it("loads canonical source options and normalizes existing values", async () => {
const wrapper = mountView() const wrapper = mountView();
await flushPromises() await flushPromises();
await openPaymentTab(wrapper) await openPaymentTab(wrapper);
const paymentSourceSelects = wrapper const paymentSourceSelects = wrapper
.findAll('select.select-stub') .findAll("select.select-stub")
.filter((node) => ['alipay', 'wxpay'].includes(node.attributes('data-placeholder'))) .filter((node) =>
["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
);
expect(paymentSourceSelects).toHaveLength(2) expect(paymentSourceSelects).toHaveLength(2);
const alipaySelect = paymentSourceSelects.find( const alipaySelect = paymentSourceSelects.find(
(node) => node.attributes('data-placeholder') === 'alipay' (node) => node.attributes("data-placeholder") === "alipay",
) );
const wxpaySelect = paymentSourceSelects.find( const wxpaySelect = paymentSourceSelects.find(
(node) => node.attributes('data-placeholder') === 'wxpay' (node) => node.attributes("data-placeholder") === "wxpay",
) );
expect(alipaySelect?.element.value).toBe('official_alipay') expect(alipaySelect?.element.value).toBe("official_alipay");
expect(alipaySelect?.findAll('option').map((option) => option.element.value)).toEqual([ expect(
'', alipaySelect?.findAll("option").map((option) => option.element.value),
'official_alipay', ).toEqual(["", "official_alipay", "easypay_alipay"]);
'easypay_alipay',
]) expect(wxpaySelect?.element.value).toBe("");
expect(
expect(wxpaySelect?.element.value).toBe('') wxpaySelect?.findAll("option").map((option) => option.element.value),
expect(wxpaySelect?.findAll('option').map((option) => option.element.value)).toEqual([ ).toEqual(["", "official_wxpay", "easypay_wxpay"]);
'', });
'official_wxpay',
'easypay_wxpay', it("saves canonical source keys selected from the dropdowns", async () => {
]) const wrapper = mountView();
})
await flushPromises();
it('saves canonical source keys selected from the dropdowns', async () => { await openPaymentTab(wrapper);
const wrapper = mountView()
await flushPromises()
await openPaymentTab(wrapper)
const paymentSourceSelects = wrapper const paymentSourceSelects = wrapper
.findAll('select.select-stub') .findAll("select.select-stub")
.filter((node) => ['alipay', 'wxpay'].includes(node.attributes('data-placeholder'))) .filter((node) =>
["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
);
const alipaySelect = paymentSourceSelects.find( const alipaySelect = paymentSourceSelects.find(
(node) => node.attributes('data-placeholder') === 'alipay' (node) => node.attributes("data-placeholder") === "alipay",
) );
const wxpaySelect = paymentSourceSelects.find( const wxpaySelect = paymentSourceSelects.find(
(node) => node.attributes('data-placeholder') === 'wxpay' (node) => node.attributes("data-placeholder") === "wxpay",
) );
await alipaySelect?.setValue('easypay_alipay') await alipaySelect?.setValue("easypay_alipay");
await wxpaySelect?.setValue('official_wxpay') await wxpaySelect?.setValue("official_wxpay");
await wrapper.find('form').trigger('submit.prevent') await wrapper.find("form").trigger("submit.prevent");
await flushPromises() await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1) expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith( expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
payment_visible_method_alipay_source: 'easypay_alipay', payment_visible_method_alipay_source: "easypay_alipay",
payment_visible_method_wxpay_source: 'official_wxpay', payment_visible_method_wxpay_source: "official_wxpay",
payment_visible_method_alipay_enabled: true, payment_visible_method_alipay_enabled: true,
payment_visible_method_wxpay_enabled: true, payment_visible_method_wxpay_enabled: true,
}) }),
) );
}) });
it('blocks saving when a visible payment method is enabled without a source', async () => { it("blocks saving when a visible payment method is enabled without a source", async () => {
const wrapper = mountView() const wrapper = mountView();
await flushPromises() await flushPromises();
await openPaymentTab(wrapper) await openPaymentTab(wrapper);
const paymentSourceSelects = wrapper const paymentSourceSelects = wrapper
.findAll('select.select-stub') .findAll("select.select-stub")
.filter((node) => ['alipay', 'wxpay'].includes(node.attributes('data-placeholder'))) .filter((node) =>
["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
);
const alipaySelect = paymentSourceSelects.find( const alipaySelect = paymentSourceSelects.find(
(node) => node.attributes('data-placeholder') === 'alipay' (node) => node.attributes("data-placeholder") === "alipay",
) );
await alipaySelect?.setValue('') await alipaySelect?.setValue("");
await wrapper.find('form').trigger('submit.prevent') await wrapper.find("form").trigger("submit.prevent");
await flushPromises() await flushPromises();
expect(updateSettings).not.toHaveBeenCalled() expect(updateSettings).not.toHaveBeenCalled();
expect(showError).toHaveBeenCalled() expect(showError).toHaveBeenCalled();
expect(String(showError.mock.calls.at(-1)?.[0] ?? '')).toContain('支付来源') expect(String(showError.mock.calls.at(-1)?.[0] ?? "")).toContain(
}) "支付来源",
);
it('renders advanced scheduler copy as local experimental gateway policy', async () => { });
const wrapper = mountView()
it("renders advanced scheduler copy as local experimental gateway policy", async () => {
await flushPromises() const wrapper = mountView();
expect(wrapper.text()).toContain('OpenAI 实验调度策略') await flushPromises();
expect(wrapper.text()).toContain('默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑')
expect(wrapper.text()).not.toContain('OpenAI 高级调度器') expect(wrapper.text()).toContain("OpenAI 实验调度策略");
}) expect(wrapper.text()).toContain(
}) "默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑",
);
expect(wrapper.text()).not.toContain("OpenAI 高级调度器");
});
});
describe("admin SettingsView wechat connect controls", () => {
beforeEach(() => {
getSettings.mockReset();
updateSettings.mockReset();
getWebSearchEmulationConfig.mockReset();
updateWebSearchEmulationConfig.mockReset();
getAdminApiKey.mockReset();
getOverloadCooldownSettings.mockReset();
getStreamTimeoutSettings.mockReset();
getRectifierSettings.mockReset();
getBetaPolicySettings.mockReset();
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
fetchPublicSettings.mockReset();
adminSettingsFetch.mockReset();
showError.mockReset();
showSuccess.mockReset();
getSettings.mockResolvedValue({
...baseSettingsResponse,
payment_visible_method_wxpay_source: "official_wxpay",
});
updateSettings.mockImplementation(async (payload) => ({
...baseSettingsResponse,
payment_visible_method_wxpay_source: "official_wxpay",
...payload,
}));
getWebSearchEmulationConfig.mockResolvedValue({
enabled: false,
providers: [],
});
updateWebSearchEmulationConfig.mockResolvedValue({
enabled: false,
providers: [],
});
getAdminApiKey.mockResolvedValue({
exists: false,
masked_key: "",
});
getOverloadCooldownSettings.mockResolvedValue({
enabled: true,
cooldown_minutes: 10,
});
getStreamTimeoutSettings.mockResolvedValue({
enabled: true,
action: "temp_unsched",
temp_unsched_minutes: 5,
threshold_count: 3,
threshold_window_minutes: 10,
});
getRectifierSettings.mockResolvedValue({
enabled: true,
thinking_signature_enabled: true,
thinking_budget_enabled: true,
apikey_signature_enabled: false,
apikey_signature_patterns: [],
});
getBetaPolicySettings.mockResolvedValue({
rules: [],
});
getGroups.mockResolvedValue([]);
listProxies.mockResolvedValue({
items: [],
});
getProviders.mockResolvedValue({
data: [],
});
fetchPublicSettings.mockResolvedValue(undefined);
adminSettingsFetch.mockResolvedValue(undefined);
});
it("loads and echoes WeChat Connect fields from the backend payload", async () => {
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
expect(
(
wrapper.get('[data-testid="wechat-connect-app-id"]')
.element as HTMLInputElement
).value,
).toBe("wx-app-id-123");
expect(
(
wrapper.get('[data-testid="wechat-connect-mode"]')
.element as HTMLSelectElement
).value,
).toBe("mp");
expect(
(
wrapper.get('[data-testid="wechat-connect-scopes"]')
.element as HTMLInputElement
).value,
).toBe("snsapi_userinfo");
expect(
wrapper
.get('[data-testid="wechat-connect-app-secret"]')
.attributes("placeholder"),
).toContain("密钥已配置");
expect(
(
wrapper.get('[data-testid="wechat-connect-frontend-redirect-url"]')
.element as HTMLInputElement
).value,
).toBe("/auth/wechat/callback");
});
it("saves WeChat Connect fields using the backend contract and clears the secret after save", async () => {
const wrapper = mountView();
await flushPromises();
await openSecurityTab(wrapper);
await wrapper
.get('[data-testid="wechat-connect-app-id"]')
.setValue("wx-app-id-updated");
await wrapper
.get('[data-testid="wechat-connect-app-secret"]')
.setValue("new-secret");
await wrapper.get('[data-testid="wechat-connect-mode"]').setValue("open");
await wrapper
.get('[data-testid="wechat-connect-scopes"]')
.setValue(" snsapi_base ");
await wrapper
.get('[data-testid="wechat-connect-redirect-url"]')
.setValue("https://admin.example.com/api/v1/auth/oauth/wechat/callback");
await wrapper
.get('[data-testid="wechat-connect-frontend-redirect-url"]')
.setValue("/auth/wechat/callback");
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
expect(updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
wechat_connect_enabled: true,
wechat_connect_app_id: "wx-app-id-updated",
wechat_connect_app_secret: "new-secret",
wechat_connect_mode: "open",
wechat_connect_scopes: "snsapi_base",
wechat_connect_redirect_url:
"https://admin.example.com/api/v1/auth/oauth/wechat/callback",
wechat_connect_frontend_redirect_url: "/auth/wechat/callback",
}),
);
expect(
(
wrapper.get('[data-testid="wechat-connect-app-secret"]')
.element as HTMLInputElement
).value,
).toBe("");
expect(
wrapper
.get('[data-testid="wechat-connect-app-secret"]')
.attributes("placeholder"),
).toContain("密钥已配置");
});
});
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment