diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index f0e91f3a..9bc20771 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -122,6 +122,13 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { LinuxDoConnectClientID: settings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, 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, OIDCConnectProviderName: settings.OIDCConnectProviderName, OIDCConnectClientID: settings.OIDCConnectClientID, @@ -246,6 +253,15 @@ type UpdateSettingsRequest struct { LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"` 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 登录 OIDCConnectEnabled bool `json:"oidc_connect_enabled"` OIDCConnectProviderName string `json:"oidc_connect_provider_name"` @@ -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 参数验证 if req.OIDCConnectEnabled { req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName) @@ -857,6 +921,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { LinuxDoConnectClientID: req.LinuxDoConnectClientID, LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, 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, OIDCConnectProviderName: req.OIDCConnectProviderName, OIDCConnectClientID: req.OIDCConnectClientID, @@ -1136,6 +1207,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, 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, OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName, OIDCConnectClientID: updatedSettings.OIDCConnectClientID, @@ -1329,6 +1407,27 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL { 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 { changed = append(changed, "oidc_connect_enabled") } diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index 5e697fb5..734fb2ef 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -8,7 +8,6 @@ import ( "io" "net/http" "net/url" - "os" "strconv" "strings" "time" @@ -149,7 +148,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) { // WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid, // and stores the result in the unified pending-auth flow. func (h *AuthHandler) WeChatOAuthCallback(c *gin.Context) { - frontendCallback := wechatOAuthFrontendCallback() + frontendCallback := h.wechatOAuthFrontendCallback(c.Request.Context()) if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" { redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description")) @@ -859,6 +858,10 @@ func (h *AuthHandler) getWeChatOAuthConfig(ctx context.Context, rawMode string, return wechatOAuthConfig{}, err } + if h == nil || h.settingSvc == nil { + return wechatOAuthConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "wechat oauth settings service not ready") + } + apiBaseURL := "" if h != nil && h.settingSvc != nil { settings, err := h.settingSvc.GetAllSettings(ctx) @@ -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{ mode: mode, - redirectURI: resolveWeChatOAuthAbsoluteURL(apiBaseURL, c, "/api/v1/auth/oauth/wechat/callback"), - frontendCallback: wechatOAuthFrontendCallback(), + appID: strings.TrimSpace(effective.AppID), + 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 { 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.scope = "snsapi_userinfo" 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.scope = "snsapi_login" - } - - if cfg.appID == "" || cfg.appSecret == "" { - return wechatOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled") } if strings.TrimSpace(cfg.redirectURI) == "" { 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, return cfg, nil } -func wechatOAuthFrontendCallback() string { - return firstNonEmpty(strings.TrimSpace(os.Getenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL")), wechatOAuthDefaultFrontendCB) +func (h *AuthHandler) wechatOAuthFrontendCallback(ctx context.Context) string { + 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) { diff --git a/backend/internal/handler/auth_wechat_oauth_test.go b/backend/internal/handler/auth_wechat_oauth_test.go index cd34f52f..b0fee617 100644 --- a/backend/internal/handler/auth_wechat_oauth_test.go +++ b/backend/internal/handler/auth_wechat_oauth_test.go @@ -33,16 +33,22 @@ import ( ) 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) + 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() c, _ := gin.CreateTestContext(recorder) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/start?mode=open&redirect=/billing", nil) c.Request.Host = "api.example.com" - handler := &AuthHandler{} handler.WeChatOAuthStart(c) require.Equal(t, http.StatusFound, recorder.Code) @@ -60,10 +66,6 @@ func TestWeChatOAuthStartRedirectsAndSetsPendingCookies(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 originalUserInfoURL := wechatOAuthUserInfoURL t.Cleanup(func() { @@ -124,10 +126,6 @@ func TestWeChatOAuthCallbackCreatesPendingSessionForUnifiedFlow(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 originalUserInfoURL := wechatOAuthUserInfoURL t.Cleanup(func() { @@ -151,7 +149,7 @@ func TestWeChatOAuthCallbackRejectsMissingUnionID(t *testing.T) { wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token" 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() recorder := httptest.NewRecorder() @@ -177,9 +175,6 @@ func TestWeChatOAuthCallbackRejectsMissingUnionID(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 t.Cleanup(func() { wechatOAuthAccessTokenURL = originalAccessTokenURL @@ -196,7 +191,7 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T) defer upstream.Close() 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() handler.cfg.Totp.EncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" @@ -240,7 +235,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test testCases := []struct { name string mode string - appIDEnv string appID string appSecret string openID string @@ -248,7 +242,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test { name: "open", mode: "open", - appIDEnv: "WECHAT_OAUTH_OPEN_APP_ID", appID: "wx-open-app", appSecret: "wx-open-secret", openID: "openid-open-123", @@ -256,7 +249,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test { name: "mp", mode: "mp", - appIDEnv: "WECHAT_OAUTH_MP_APP_ID", appID: "wx-mp-app", appSecret: "wx-mp-secret", openID: "openid-mp-123", @@ -265,15 +257,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test for _, tc := range testCases { 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 originalUserInfoURL := wechatOAuthUserInfoURL t.Cleanup(func() { @@ -297,7 +280,7 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test wechatOAuthAccessTokenURL = upstream.URL + "/sns/oauth2/access_token" 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() currentUser, err := client.User.Create(). @@ -354,10 +337,6 @@ func TestWeChatOAuthCallbackBindUsesUnionCanonicalIdentityAcrossChannels(t *test } 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 originalUserInfoURL := wechatOAuthUserInfoURL t.Cleanup(func() { @@ -436,10 +415,6 @@ func TestWeChatOAuthCallbackBindRejectsCanonicalOwnershipConflict(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 originalUserInfoURL := wechatOAuthUserInfoURL t.Cleanup(func() { @@ -529,10 +504,6 @@ func TestWeChatOAuthCallbackBindRejectsChannelOwnershipConflict(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 originalUserInfoURL := wechatOAuthUserInfoURL t.Cleanup(func() { @@ -611,10 +582,6 @@ func TestWeChatOAuthCallbackBindRejectsLegacyProviderKeyOwnershipConflict(t *tes } 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 originalUserInfoURL := wechatOAuthUserInfoURL t.Cleanup(func() { @@ -737,10 +704,6 @@ func TestCompleteWeChatOAuthRegistrationAfterInvitationPendingSession(t *testing } 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 originalUserInfoURL := wechatOAuthUserInfoURL t.Cleanup(func() { @@ -900,10 +863,6 @@ func TestCompleteWeChatOAuthRegistrationRejectsAdoptExistingUserSession(t *testi } 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 originalUserInfoURL := wechatOAuthUserInfoURL t.Cleanup(func() { @@ -1010,6 +969,22 @@ func TestWeChatOAuthCallbackRepairsLegacyProviderKeyCanonicalIdentity(t *testing } 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() 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 UserConcurrency: 1, }, } - settingSvc := service.NewSettingService(&wechatOAuthSettingRepoStub{ - values: map[string]string{ - service.SettingKeyRegistrationEnabled: "true", - service.SettingKeyInvitationCodeEnabled: boolSettingValue(invitationEnabled), - }, - }, cfg) + values := map[string]string{ + service.SettingKeyRegistrationEnabled: "true", + service.SettingKeyInvitationCodeEnabled: boolSettingValue(invitationEnabled), + } + 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( client, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index d67b29a0..4c5edfbf 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -51,6 +51,14 @@ type SystemSettings struct { LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"` 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"` OIDCConnectProviderName string `json:"oidc_connect_provider_name"` OIDCConnectClientID string `json:"oidc_connect_client_id"` diff --git a/backend/internal/handler/setting_handler_public_test.go b/backend/internal/handler/setting_handler_public_test.go index b50c982c..628d9341 100644 --- a/backend/internal/handler/setting_handler_public_test.go +++ b/backend/internal/handler/setting_handler_public_test.go @@ -84,12 +84,17 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) { gin.SetMode(gin.TestMode) - t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app") - t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret") - t.Setenv("WECHAT_OAUTH_MP_APP_ID", "") - t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "") - - h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{}, &config.Config{}), "test-version") + h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{ + values: map[string]string{ + service.SettingKeyWeChatConnectEnabled: "true", + service.SettingKeyWeChatConnectAppID: "wx-mp-app", + service.SettingKeyWeChatConnectAppSecret: "wx-mp-secret", + 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() c, _ := gin.CreateTestContext(recorder) @@ -110,6 +115,6 @@ func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t * require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) require.Equal(t, 0, resp.Code) require.True(t, resp.Data.WeChatOAuthEnabled) - require.True(t, resp.Data.WeChatOAuthOpenEnabled) - require.False(t, resp.Data.WeChatOAuthMPEnabled) + require.False(t, resp.Data.WeChatOAuthOpenEnabled) + require.True(t, resp.Data.WeChatOAuthMPEnabled) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 1dddf77e..4d63cabc 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -111,6 +111,15 @@ const ( SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret" 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 登录设置 SettingKeyOIDCConnectEnabled = "oidc_connect_enabled" SettingKeyOIDCConnectProviderName = "oidc_connect_provider_name" diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index 34462a3a..2d1f3f42 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -93,6 +93,11 @@ type UpdatePaymentConfigRequest struct { CancelRateLimitWindow *int `json:"cancel_rate_limit_window"` CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"` 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. @@ -319,6 +324,10 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow), SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit), SettingCancelWindowMode: derefStr(req.CancelRateLimitMode), + SettingPaymentVisibleMethodAlipaySource: derefStr(req.VisibleMethodAlipaySource), + SettingPaymentVisibleMethodWxpaySource: derefStr(req.VisibleMethodWxpaySource), + SettingPaymentVisibleMethodAlipayEnabled: formatBoolOrEmpty(req.VisibleMethodAlipayEnabled), + SettingPaymentVisibleMethodWxpayEnabled: formatBoolOrEmpty(req.VisibleMethodWxpayEnabled), } if req.EnabledTypes != nil { m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",") diff --git a/backend/internal/service/payment_config_service_test.go b/backend/internal/service/payment_config_service_test.go index 10919058..d58ee234 100644 --- a/backend/internal/service/payment_config_service_test.go +++ b/backend/internal/service/payment_config_service_test.go @@ -366,7 +366,8 @@ func newPaymentConfigServiceTestClient(t *testing.T) *dbent.Client { } type paymentConfigSettingRepoStub struct { - values map[string]string + values map[string]string + updates map[string]string } func (s *paymentConfigSettingRepoStub) Get(context.Context, string) (*Setting, error) { @@ -383,10 +384,52 @@ func (s *paymentConfigSettingRepoStub) GetMultiple(_ context.Context, keys []str } 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 } func (s *paymentConfigSettingRepoStub) GetAll(context.Context) (map[string]string, error) { return s.values, 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 +} diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 354f3cd1..01d8c642 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -6,7 +6,6 @@ import ( "log/slog" "math" "net/url" - "os" "strconv" "strings" "time" @@ -512,16 +511,21 @@ func requiresWeChatJSAPICompatibleSelection(req CreateOrderRequest, sel *payment return req.IsWeChatBrowser || strings.TrimSpace(req.OpenID) != "" } -func (s *PaymentService) getWeChatPaymentOAuthCredential(context.Context) (string, string, error) { - appID := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID")) - appSecret := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET")) - if appID == "" || appSecret == "" { +func (s *PaymentService) getWeChatPaymentOAuthCredential(ctx context.Context) (string, string, error) { + if s == nil || s.configService == nil || s.configService.settingRepo == nil { return "", "", infraerrors.ServiceUnavailable( "WECHAT_PAYMENT_MP_NOT_CONFIGURED", "wechat in-app payment requires a complete WeChat MP OAuth credential", ) } - return appID, appSecret, nil + 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( + "WECHAT_PAYMENT_MP_NOT_CONFIGURED", + "wechat in-app payment requires a complete WeChat MP OAuth credential", + ) + } + return strings.TrimSpace(cfg.AppID), strings.TrimSpace(cfg.AppSecret), nil } func classifyCreatePaymentError(req CreateOrderRequest, providerKey string, err error) error { diff --git a/backend/internal/service/payment_order_jsapi_test.go b/backend/internal/service/payment_order_jsapi_test.go index 08492432..25f209af 100644 --- a/backend/internal/service/payment_order_jsapi_test.go +++ b/backend/internal/service/payment_order_jsapi_test.go @@ -60,10 +60,17 @@ func TestSelectCreateOrderInstancePrefersJSAPICompatibleWxpayInstance(t *testing } configService := &PaymentConfigService{ - entClient: client, + entClient: client, settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{ - SettingPaymentVisibleMethodWxpayEnabled: "true", - SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat, + SettingPaymentVisibleMethodWxpayEnabled: "true", + 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), } @@ -77,9 +84,6 @@ func TestSelectCreateOrderInstancePrefersJSAPICompatibleWxpayInstance(t *testing 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{ PaymentType: payment.TypeWxpay, OpenID: "openid-123", diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go index 0daa8213..16757323 100644 --- a/backend/internal/service/payment_order_result_test.go +++ b/backend/internal/service/payment_order_result_test.go @@ -91,10 +91,15 @@ func TestBuildCreateOrderResponseCopiesJSAPIPayload(t *testing.T) { } func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) { - t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx123456") - t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret") - - svc := &PaymentService{} + svc := newWeChatPaymentOAuthTestService(map[string]string{ + SettingKeyWeChatConnectEnabled: "true", + SettingKeyWeChatConnectAppID: "wx123456", + 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{ Amount: 12.5, @@ -132,7 +137,7 @@ func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) { func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testing.T) { t.Parallel() - svc := &PaymentService{} + svc := newWeChatPaymentOAuthTestService(nil) resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{ Amount: 12.5, @@ -155,10 +160,15 @@ func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testin } func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t *testing.T) { - t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx123456") - t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wechat-secret") - - svc := &PaymentService{} + svc := newWeChatPaymentOAuthTestService(map[string]string{ + SettingKeyWeChatConnectEnabled: "true", + SettingKeyWeChatConnectAppID: "wx123456", + 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{ Amount: 12.5, @@ -175,3 +185,11 @@ func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t t.Fatalf("expected nil response, got %+v", resp) } } + +func newWeChatPaymentOAuthTestService(values map[string]string) *PaymentService { + return &PaymentService{ + configService: &PaymentConfigService{ + settingRepo: &paymentConfigSettingRepoStub{values: values}, + }, + } +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 8c879d52..373c1aef 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -9,7 +9,6 @@ import ( "fmt" "log/slog" "net/url" - "os" "sort" "strconv" "strings" @@ -173,8 +172,43 @@ var ( const ( defaultAuthSourceBalance = 0 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 创建系统设置服务实例 func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService { return &SettingService{ @@ -240,6 +274,13 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyCustomMenuItems, SettingKeyCustomEndpoints, SettingKeyLinuxDoConnectEnabled, + SettingKeyWeChatConnectEnabled, + SettingKeyWeChatConnectAppID, + SettingKeyWeChatConnectAppSecret, + SettingKeyWeChatConnectMode, + SettingKeyWeChatConnectScopes, + SettingKeyWeChatConnectRedirectURL, + SettingKeyWeChatConnectFrontendRedirectURL, SettingKeyBackendModeEnabled, SettingPaymentEnabled, SettingKeyOIDCConnectEnabled, @@ -274,9 +315,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings if oidcProviderName == "" { oidcProviderName = "OIDC" } - weChatOpenEnabled := isWeChatOAuthOpenConfigured() - weChatMPEnabled := isWeChatOAuthMPConfigured() - weChatEnabled := weChatOpenEnabled || weChatMPEnabled + weChatEnabled, weChatOpenEnabled, weChatMPEnabled := s.weChatOAuthCapabilitiesFromSettings(settings) // Password reset requires email verification to be enabled emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" @@ -431,6 +470,56 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any }, 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 // array string, returning only items with visibility != "admin". func filterUserVisibleMenuItems(raw string) json.RawMessage { @@ -467,20 +556,6 @@ func filterUserVisibleMenuItems(raw string) json.RawMessage { 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 "[]". func safeRawJSONArray(raw string) json.RawMessage { raw = strings.TrimSpace(raw) @@ -625,6 +700,15 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting } settings.PaymentVisibleMethodAlipaySource = alipaySource 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) @@ -694,6 +778,17 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting 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设置 updates[SettingKeySiteName] = settings.SiteName updates[SettingKeySiteLogo] = settings.SiteLogo @@ -1200,6 +1295,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyTablePageSizeOptions: "[10,20,50,100]", SettingKeyCustomMenuItems: "[]", SettingKeyCustomEndpoints: "[]", + SettingKeyWeChatConnectEnabled: "false", + SettingKeyWeChatConnectMode: "open", + SettingKeyWeChatConnectScopes: "snsapi_login", + SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend, SettingKeyOIDCConnectEnabled: "false", SettingKeyOIDCConnectProviderName: "OIDC", SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), @@ -1491,6 +1590,19 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } 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 result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true" result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022") @@ -1972,6 +2084,26 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf 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过载冷却配置 func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) { value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings) diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 4cfa9f0c..ffc069dc 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -92,16 +92,21 @@ func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t } func TestSettingService_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(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_MP_APP_ID", "") - t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "") - - svc := NewSettingService(&settingPublicRepoStub{}, &config.Config{}) + svc := NewSettingService(&settingPublicRepoStub{ + values: map[string]string{ + SettingKeyWeChatConnectEnabled: "true", + SettingKeyWeChatConnectAppID: "wx-mp-app", + SettingKeyWeChatConnectAppSecret: "wx-mp-secret", + 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()) require.NoError(t, err) require.True(t, settings.WeChatOAuthEnabled) - require.True(t, settings.WeChatOAuthOpenEnabled) - require.False(t, settings.WeChatOAuthMPEnabled) + require.False(t, settings.WeChatOAuthOpenEnabled) + require.True(t, settings.WeChatOAuthMPEnabled) } diff --git a/backend/internal/service/setting_service_wechat_config_test.go b/backend/internal/service/setting_service_wechat_config_test.go new file mode 100644 index 00000000..2cb312cc --- /dev/null +++ b/backend/internal/service/setting_service_wechat_config_test.go @@ -0,0 +1,77 @@ +//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) +} diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 1b859dbd..41229bfb 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -31,6 +31,16 @@ type SystemSettings struct { LinuxDoConnectClientSecretConfigured bool LinuxDoConnectRedirectURL string + // WeChat Connect OAuth 登录 + WeChatConnectEnabled bool + WeChatConnectAppID string + WeChatConnectAppSecret string + WeChatConnectAppSecretConfigured bool + WeChatConnectMode string + WeChatConnectScopes string + WeChatConnectRedirectURL string + WeChatConnectFrontendRedirectURL string + // Generic OIDC OAuth 登录 OIDCConnectEnabled bool OIDCConnectProviderName string @@ -177,6 +187,16 @@ type PublicSettings struct { BalanceLowNotifyRechargeURL string } +type WeChatConnectOAuthConfig struct { + Enabled bool + AppID string + AppSecret string + Mode string + Scopes string + RedirectURL string + FrontendRedirectURL string +} + // StreamTimeoutSettings 流超时处理配置(仅控制超时后的处理方式,超时判定由网关配置控制) type StreamTimeoutSettings struct { // Enabled 是否启用流超时处理 diff --git a/frontend/src/api/__tests__/settings.wechatConnect.spec.ts b/frontend/src/api/__tests__/settings.wechatConnect.spec.ts new file mode 100644 index 00000000..eccb7214 --- /dev/null +++ b/frontend/src/api/__tests__/settings.wechatConnect.spec.ts @@ -0,0 +1,21 @@ +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"); + }); +}); diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 235bda7b..ed78a1bc 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -3,155 +3,240 @@ * Handles system settings management for administrators */ -import { apiClient } from '../client' -import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from '@/types' +import { apiClient } from "../client"; +import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from "@/types"; export interface DefaultSubscriptionSetting { - group_id: number - validity_days: number + group_id: number; + validity_days: number; } -export type AuthSourceType = 'email' | 'linuxdo' | 'oidc' | 'wechat' +export type AuthSourceType = "email" | "linuxdo" | "oidc" | "wechat"; export interface AuthSourceDefaultsValue { - balance: number - concurrency: number - subscriptions: DefaultSubscriptionSetting[] - grant_on_signup: boolean - grant_on_first_bind: boolean + balance: number; + concurrency: number; + subscriptions: DefaultSubscriptionSetting[]; + grant_on_signup: boolean; + grant_on_first_bind: boolean; } -export type AuthSourceDefaultsState = Record -export type PaymentVisibleMethod = 'alipay' | 'wxpay' +export type AuthSourceDefaultsState = Record< + AuthSourceType, + AuthSourceDefaultsValue +>; +export type PaymentVisibleMethod = "alipay" | "wxpay"; export type PaymentVisibleMethodSource = - | '' - | 'official_alipay' - | 'easypay_alipay' - | 'official_wxpay' - | 'easypay_wxpay' + | "" + | "official_alipay" + | "easypay_alipay" + | "official_wxpay" + | "easypay_wxpay"; +export type WeChatConnectMode = "open" | "mp"; export interface PaymentVisibleMethodSourceOption { - value: PaymentVisibleMethodSource - labelZh: string - labelEn: string + value: PaymentVisibleMethodSource; + labelZh: string; + labelEn: string; } -const AUTH_SOURCE_TYPES: AuthSourceType[] = ['email', 'linuxdo', 'oidc', 'wechat'] -const AUTH_SOURCE_DEFAULT_BALANCE = 0 -const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5 +export interface WeChatConnectModeOption { + value: WeChatConnectMode; + labelZh: string; + labelEn: string; +} + +const AUTH_SOURCE_TYPES: AuthSourceType[] = [ + "email", + "linuxdo", + "oidc", + "wechat", +]; +const AUTH_SOURCE_DEFAULT_BALANCE = 0; +const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5; const PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS: Record< PaymentVisibleMethod, PaymentVisibleMethodSourceOption[] > = { alipay: [ - { value: '', labelZh: '未配置', labelEn: 'Not configured' }, - { value: 'official_alipay', labelZh: '支付宝官方', labelEn: 'Official Alipay' }, - { value: 'easypay_alipay', labelZh: '易支付支付宝', labelEn: 'EasyPay Alipay' }, + { value: "", labelZh: "未配置", labelEn: "Not configured" }, + { + value: "official_alipay", + labelZh: "支付宝官方", + labelEn: "Official Alipay", + }, + { + value: "easypay_alipay", + labelZh: "易支付支付宝", + labelEn: "EasyPay Alipay", + }, ], wxpay: [ - { value: '', labelZh: '未配置', labelEn: 'Not configured' }, - { value: 'official_wxpay', labelZh: '微信官方', labelEn: 'Official WeChat Pay' }, - { value: 'easypay_wxpay', labelZh: '易支付微信', labelEn: 'EasyPay WeChat Pay' }, + { value: "", labelZh: "未配置", labelEn: "Not configured" }, + { + value: "official_wxpay", + labelZh: "微信官方", + labelEn: "Official WeChat Pay", + }, + { + value: "easypay_wxpay", + labelZh: "易支付微信", + labelEn: "EasyPay WeChat Pay", + }, ], -} +}; const PAYMENT_VISIBLE_METHOD_SOURCE_ALIASES: Record< PaymentVisibleMethod, Record > = { alipay: { - official_alipay: 'official_alipay', - alipay: 'official_alipay', - alipay_direct: 'official_alipay', - official: 'official_alipay', - easypay_alipay: 'easypay_alipay', - easypay: 'easypay_alipay', + official_alipay: "official_alipay", + alipay: "official_alipay", + alipay_direct: "official_alipay", + official: "official_alipay", + easypay_alipay: "easypay_alipay", + easypay: "easypay_alipay", }, wxpay: { - official_wxpay: 'official_wxpay', - wxpay: 'official_wxpay', - wxpay_direct: 'official_wxpay', - wechat: 'official_wxpay', - official: 'official_wxpay', - easypay_wxpay: 'easypay_wxpay', - easypay: 'easypay_wxpay', + official_wxpay: "official_wxpay", + wxpay: "official_wxpay", + wxpay_direct: "official_wxpay", + wechat: "official_wxpay", + official: "official_wxpay", + easypay_wxpay: "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 = { + open: "open", + open_platform: "open", + official: "open", + wx_open: "open", + mp: "mp", + official_account: "mp", + wechat_mp: "mp", + mini_program: "mp", +}; export function normalizeDefaultSubscriptionSettings( - subscriptions: DefaultSubscriptionSetting[] | null | undefined + subscriptions: DefaultSubscriptionSetting[] | null | undefined, ): DefaultSubscriptionSetting[] { - if (!Array.isArray(subscriptions)) return [] + if (!Array.isArray(subscriptions)) return []; return subscriptions .filter((item) => item.group_id > 0 && item.validity_days > 0) .map((item) => ({ 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( - settings: Partial + settings: Partial, ): AuthSourceDefaultsState { - const raw = settings as Record + const raw = settings as Record; 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] = { - 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( 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( - 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_first_bind: raw[`auth_source_default_${source}_grant_on_first_bind`] === true, - } - return acc - }, {} as AuthSourceDefaultsState) + grant_on_signup: + raw[`auth_source_default_${source}_grant_on_signup`] !== false, + grant_on_first_bind: + raw[`auth_source_default_${source}_grant_on_first_bind`] === true, + }; + return acc; + }, {} as AuthSourceDefaultsState); } export function appendAuthSourceDefaultsToUpdateRequest( payload: UpdateSettingsRequest, - authSourceDefaults: AuthSourceDefaultsState + authSourceDefaults: AuthSourceDefaultsState, ): UpdateSettingsRequest { - const target = payload as Record + const target = payload as Record; for (const source of AUTH_SOURCE_TYPES) { - const current = authSourceDefaults[source] - target[`auth_source_default_${source}_balance`] = Number(current.balance) || 0 + const current = authSourceDefaults[source]; + target[`auth_source_default_${source}_balance`] = + Number(current.balance) || 0; target[`auth_source_default_${source}_concurrency`] = Math.max( 1, - Math.floor(Number(current.concurrency) || AUTH_SOURCE_DEFAULT_CONCURRENCY) - ) - target[`auth_source_default_${source}_subscriptions`] = normalizeDefaultSubscriptionSettings( - current.subscriptions - ) - 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 + Math.floor( + Number(current.concurrency) || AUTH_SOURCE_DEFAULT_CONCURRENCY, + ), + ); + target[`auth_source_default_${source}_subscriptions`] = + normalizeDefaultSubscriptionSettings(current.subscriptions); + 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( - method: PaymentVisibleMethod + method: PaymentVisibleMethod, ): PaymentVisibleMethodSourceOption[] { - return PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS[method] + return PAYMENT_VISIBLE_METHOD_SOURCE_OPTIONS[method]; } export function normalizePaymentVisibleMethodSource( method: PaymentVisibleMethod, - source: unknown + source: unknown, ): PaymentVisibleMethodSource { - if (typeof source !== 'string') return '' + if (typeof source !== "string") return ""; - const normalized = source.trim().toLowerCase() - if (!normalized) return '' + const normalized = source.trim().toLowerCase(); + 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( */ export interface SystemSettings { // Registration settings - registration_enabled: boolean - email_verify_enabled: boolean - registration_email_suffix_whitelist: string[] - promo_code_enabled: boolean - password_reset_enabled: boolean - frontend_url: string - invitation_code_enabled: boolean - totp_enabled: boolean // TOTP 双因素认证 - totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置 + registration_enabled: boolean; + email_verify_enabled: boolean; + registration_email_suffix_whitelist: string[]; + promo_code_enabled: boolean; + password_reset_enabled: boolean; + frontend_url: string; + invitation_code_enabled: boolean; + totp_enabled: boolean; // TOTP 双因素认证 + totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置 // Default settings - default_balance: number - default_concurrency: number - default_subscriptions: DefaultSubscriptionSetting[] - auth_source_default_email_balance?: number - auth_source_default_email_concurrency?: number - auth_source_default_email_subscriptions?: DefaultSubscriptionSetting[] - auth_source_default_email_grant_on_signup?: boolean - auth_source_default_email_grant_on_first_bind?: boolean - auth_source_default_linuxdo_balance?: number - auth_source_default_linuxdo_concurrency?: number - auth_source_default_linuxdo_subscriptions?: DefaultSubscriptionSetting[] - auth_source_default_linuxdo_grant_on_signup?: boolean - auth_source_default_linuxdo_grant_on_first_bind?: boolean - auth_source_default_oidc_balance?: number - auth_source_default_oidc_concurrency?: number - auth_source_default_oidc_subscriptions?: DefaultSubscriptionSetting[] - auth_source_default_oidc_grant_on_signup?: boolean - auth_source_default_oidc_grant_on_first_bind?: boolean - auth_source_default_wechat_balance?: number - auth_source_default_wechat_concurrency?: number - auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[] - auth_source_default_wechat_grant_on_signup?: boolean - auth_source_default_wechat_grant_on_first_bind?: boolean - force_email_on_third_party_signup?: boolean + default_balance: number; + default_concurrency: number; + default_subscriptions: DefaultSubscriptionSetting[]; + auth_source_default_email_balance?: number; + auth_source_default_email_concurrency?: number; + auth_source_default_email_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_email_grant_on_signup?: boolean; + auth_source_default_email_grant_on_first_bind?: boolean; + auth_source_default_linuxdo_balance?: number; + auth_source_default_linuxdo_concurrency?: number; + auth_source_default_linuxdo_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_linuxdo_grant_on_signup?: boolean; + auth_source_default_linuxdo_grant_on_first_bind?: boolean; + auth_source_default_oidc_balance?: number; + auth_source_default_oidc_concurrency?: number; + auth_source_default_oidc_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_oidc_grant_on_signup?: boolean; + auth_source_default_oidc_grant_on_first_bind?: boolean; + auth_source_default_wechat_balance?: number; + auth_source_default_wechat_concurrency?: number; + auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_wechat_grant_on_signup?: boolean; + auth_source_default_wechat_grant_on_first_bind?: boolean; + force_email_on_third_party_signup?: boolean; // OEM settings - site_name: string - site_logo: string - site_subtitle: string - api_base_url: string - contact_info: string - doc_url: string - home_content: string - hide_ccs_import_button: boolean - table_default_page_size: number - table_page_size_options: number[] - backend_mode_enabled: boolean - custom_menu_items: CustomMenuItem[] - custom_endpoints: CustomEndpoint[] + site_name: string; + site_logo: string; + site_subtitle: string; + api_base_url: string; + contact_info: string; + doc_url: string; + home_content: string; + hide_ccs_import_button: boolean; + table_default_page_size: number; + table_page_size_options: number[]; + backend_mode_enabled: boolean; + custom_menu_items: CustomMenuItem[]; + custom_endpoints: CustomEndpoint[]; // SMTP settings - smtp_host: string - smtp_port: number - smtp_username: string - smtp_password_configured: boolean - smtp_from_email: string - smtp_from_name: string - smtp_use_tls: boolean + smtp_host: string; + smtp_port: number; + smtp_username: string; + smtp_password_configured: boolean; + smtp_from_email: string; + smtp_from_name: string; + smtp_use_tls: boolean; // Cloudflare Turnstile settings - turnstile_enabled: boolean - turnstile_site_key: string - turnstile_secret_key_configured: boolean + turnstile_enabled: boolean; + turnstile_site_key: string; + turnstile_secret_key_configured: boolean; // LinuxDo Connect OAuth settings - linuxdo_connect_enabled: boolean - linuxdo_connect_client_id: string - linuxdo_connect_client_secret_configured: boolean - linuxdo_connect_redirect_url: string + linuxdo_connect_enabled: boolean; + linuxdo_connect_client_id: string; + linuxdo_connect_client_secret_configured: boolean; + 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 - oidc_connect_enabled: boolean - oidc_connect_provider_name: string - oidc_connect_client_id: string - oidc_connect_client_secret_configured: boolean - oidc_connect_issuer_url: string - oidc_connect_discovery_url: string - oidc_connect_authorize_url: string - oidc_connect_token_url: string - oidc_connect_userinfo_url: string - oidc_connect_jwks_url: string - oidc_connect_scopes: string - oidc_connect_redirect_url: string - oidc_connect_frontend_redirect_url: string - oidc_connect_token_auth_method: string - oidc_connect_use_pkce: boolean - oidc_connect_validate_id_token: boolean - oidc_connect_allowed_signing_algs: string - oidc_connect_clock_skew_seconds: number - oidc_connect_require_email_verified: boolean - oidc_connect_userinfo_email_path: string - oidc_connect_userinfo_id_path: string - oidc_connect_userinfo_username_path: string + oidc_connect_enabled: boolean; + oidc_connect_provider_name: string; + oidc_connect_client_id: string; + oidc_connect_client_secret_configured: boolean; + oidc_connect_issuer_url: string; + oidc_connect_discovery_url: string; + oidc_connect_authorize_url: string; + oidc_connect_token_url: string; + oidc_connect_userinfo_url: string; + oidc_connect_jwks_url: string; + oidc_connect_scopes: string; + oidc_connect_redirect_url: string; + oidc_connect_frontend_redirect_url: string; + oidc_connect_token_auth_method: string; + oidc_connect_use_pkce: boolean; + oidc_connect_validate_id_token: boolean; + oidc_connect_allowed_signing_algs: string; + oidc_connect_clock_skew_seconds: number; + oidc_connect_require_email_verified: boolean; + oidc_connect_userinfo_email_path: string; + oidc_connect_userinfo_id_path: string; + oidc_connect_userinfo_username_path: string; // Model fallback configuration - enable_model_fallback: boolean - fallback_model_anthropic: string - fallback_model_openai: string - fallback_model_gemini: string - fallback_model_antigravity: string + enable_model_fallback: boolean; + fallback_model_anthropic: string; + fallback_model_openai: string; + fallback_model_gemini: string; + fallback_model_antigravity: string; // Identity patch configuration (Claude -> Gemini) - enable_identity_patch: boolean - identity_patch_prompt: string + enable_identity_patch: boolean; + identity_patch_prompt: string; // Ops Monitoring (vNext) - ops_monitoring_enabled: boolean - ops_realtime_monitoring_enabled: boolean - ops_query_mode_default: 'auto' | 'raw' | 'preagg' | string - ops_metrics_interval_seconds: number + ops_monitoring_enabled: boolean; + ops_realtime_monitoring_enabled: boolean; + ops_query_mode_default: "auto" | "raw" | "preagg" | string; + ops_metrics_interval_seconds: number; // Claude Code version check - min_claude_code_version: string - max_claude_code_version: string + min_claude_code_version: string; + max_claude_code_version: string; // 分组隔离 - allow_ungrouped_key_scheduling: boolean + allow_ungrouped_key_scheduling: boolean; // Gateway forwarding behavior - enable_fingerprint_unification: boolean - enable_metadata_passthrough: boolean - enable_cch_signing: boolean - web_search_emulation_enabled?: boolean + enable_fingerprint_unification: boolean; + enable_metadata_passthrough: boolean; + enable_cch_signing: boolean; + web_search_emulation_enabled?: boolean; // Payment configuration - payment_enabled: boolean - payment_min_amount: number - payment_max_amount: number - payment_daily_limit: number - payment_order_timeout_minutes: number - payment_max_pending_orders: number - payment_enabled_types: string[] - payment_balance_disabled: boolean - payment_balance_recharge_multiplier: number - payment_recharge_fee_rate: number - payment_load_balance_strategy: string - payment_product_name_prefix: string - payment_product_name_suffix: string - payment_help_image_url: string - payment_help_text: string - payment_cancel_rate_limit_enabled: boolean - payment_cancel_rate_limit_max: number - payment_cancel_rate_limit_window: number - payment_cancel_rate_limit_unit: string - payment_cancel_rate_limit_window_mode: string - payment_visible_method_alipay_source?: string - payment_visible_method_wxpay_source?: string - payment_visible_method_alipay_enabled?: boolean - payment_visible_method_wxpay_enabled?: boolean - openai_advanced_scheduler_enabled?: boolean + payment_enabled: boolean; + payment_min_amount: number; + payment_max_amount: number; + payment_daily_limit: number; + payment_order_timeout_minutes: number; + payment_max_pending_orders: number; + payment_enabled_types: string[]; + payment_balance_disabled: boolean; + payment_balance_recharge_multiplier: number; + payment_recharge_fee_rate: number; + payment_load_balance_strategy: string; + payment_product_name_prefix: string; + payment_product_name_suffix: string; + payment_help_image_url: string; + payment_help_text: string; + payment_cancel_rate_limit_enabled: boolean; + payment_cancel_rate_limit_max: number; + payment_cancel_rate_limit_window: number; + payment_cancel_rate_limit_unit: string; + payment_cancel_rate_limit_window_mode: string; + payment_visible_method_alipay_source?: string; + payment_visible_method_wxpay_source?: string; + payment_visible_method_alipay_enabled?: boolean; + payment_visible_method_wxpay_enabled?: boolean; + openai_advanced_scheduler_enabled?: boolean; // Balance & quota notification - balance_low_notify_enabled: boolean - balance_low_notify_threshold: number - balance_low_notify_recharge_url: string - account_quota_notify_enabled: boolean - account_quota_notify_emails: NotifyEmailEntry[] + balance_low_notify_enabled: boolean; + balance_low_notify_threshold: number; + balance_low_notify_recharge_url: string; + account_quota_notify_enabled: boolean; + account_quota_notify_emails: NotifyEmailEntry[]; } export interface UpdateSettingsRequest { - registration_enabled?: boolean - email_verify_enabled?: boolean - registration_email_suffix_whitelist?: string[] - promo_code_enabled?: boolean - password_reset_enabled?: boolean - frontend_url?: string - invitation_code_enabled?: boolean - totp_enabled?: boolean // TOTP 双因素认证 - default_balance?: number - default_concurrency?: number - default_subscriptions?: DefaultSubscriptionSetting[] - auth_source_default_email_balance?: number - auth_source_default_email_concurrency?: number - auth_source_default_email_subscriptions?: DefaultSubscriptionSetting[] - auth_source_default_email_grant_on_signup?: boolean - auth_source_default_email_grant_on_first_bind?: boolean - auth_source_default_linuxdo_balance?: number - auth_source_default_linuxdo_concurrency?: number - auth_source_default_linuxdo_subscriptions?: DefaultSubscriptionSetting[] - auth_source_default_linuxdo_grant_on_signup?: boolean - auth_source_default_linuxdo_grant_on_first_bind?: boolean - auth_source_default_oidc_balance?: number - auth_source_default_oidc_concurrency?: number - auth_source_default_oidc_subscriptions?: DefaultSubscriptionSetting[] - auth_source_default_oidc_grant_on_signup?: boolean - auth_source_default_oidc_grant_on_first_bind?: boolean - auth_source_default_wechat_balance?: number - auth_source_default_wechat_concurrency?: number - auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[] - auth_source_default_wechat_grant_on_signup?: boolean - auth_source_default_wechat_grant_on_first_bind?: boolean - force_email_on_third_party_signup?: boolean - site_name?: string - site_logo?: string - site_subtitle?: string - api_base_url?: string - contact_info?: string - doc_url?: string - home_content?: string - hide_ccs_import_button?: boolean - table_default_page_size?: number - table_page_size_options?: number[] - backend_mode_enabled?: boolean - custom_menu_items?: CustomMenuItem[] - custom_endpoints?: CustomEndpoint[] - smtp_host?: string - smtp_port?: number - smtp_username?: string - smtp_password?: string - smtp_from_email?: string - smtp_from_name?: string - smtp_use_tls?: boolean - turnstile_enabled?: boolean - turnstile_site_key?: string - turnstile_secret_key?: string - linuxdo_connect_enabled?: boolean - linuxdo_connect_client_id?: string - linuxdo_connect_client_secret?: string - linuxdo_connect_redirect_url?: string - oidc_connect_enabled?: boolean - oidc_connect_provider_name?: string - oidc_connect_client_id?: string - oidc_connect_client_secret?: string - oidc_connect_issuer_url?: string - oidc_connect_discovery_url?: string - oidc_connect_authorize_url?: string - oidc_connect_token_url?: string - oidc_connect_userinfo_url?: string - oidc_connect_jwks_url?: string - oidc_connect_scopes?: string - oidc_connect_redirect_url?: string - oidc_connect_frontend_redirect_url?: string - oidc_connect_token_auth_method?: string - oidc_connect_use_pkce?: boolean - oidc_connect_validate_id_token?: boolean - oidc_connect_allowed_signing_algs?: string - oidc_connect_clock_skew_seconds?: number - oidc_connect_require_email_verified?: boolean - oidc_connect_userinfo_email_path?: string - oidc_connect_userinfo_id_path?: string - oidc_connect_userinfo_username_path?: string - enable_model_fallback?: boolean - fallback_model_anthropic?: string - fallback_model_openai?: string - fallback_model_gemini?: string - fallback_model_antigravity?: string - enable_identity_patch?: boolean - identity_patch_prompt?: string - ops_monitoring_enabled?: boolean - ops_realtime_monitoring_enabled?: 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 + registration_enabled?: boolean; + email_verify_enabled?: boolean; + registration_email_suffix_whitelist?: string[]; + promo_code_enabled?: boolean; + password_reset_enabled?: boolean; + frontend_url?: string; + invitation_code_enabled?: boolean; + totp_enabled?: boolean; // TOTP 双因素认证 + default_balance?: number; + default_concurrency?: number; + default_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_email_balance?: number; + auth_source_default_email_concurrency?: number; + auth_source_default_email_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_email_grant_on_signup?: boolean; + auth_source_default_email_grant_on_first_bind?: boolean; + auth_source_default_linuxdo_balance?: number; + auth_source_default_linuxdo_concurrency?: number; + auth_source_default_linuxdo_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_linuxdo_grant_on_signup?: boolean; + auth_source_default_linuxdo_grant_on_first_bind?: boolean; + auth_source_default_oidc_balance?: number; + auth_source_default_oidc_concurrency?: number; + auth_source_default_oidc_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_oidc_grant_on_signup?: boolean; + auth_source_default_oidc_grant_on_first_bind?: boolean; + auth_source_default_wechat_balance?: number; + auth_source_default_wechat_concurrency?: number; + auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_wechat_grant_on_signup?: boolean; + auth_source_default_wechat_grant_on_first_bind?: boolean; + force_email_on_third_party_signup?: boolean; + site_name?: string; + site_logo?: string; + site_subtitle?: string; + api_base_url?: string; + contact_info?: string; + doc_url?: string; + home_content?: string; + hide_ccs_import_button?: boolean; + table_default_page_size?: number; + table_page_size_options?: number[]; + backend_mode_enabled?: boolean; + custom_menu_items?: CustomMenuItem[]; + custom_endpoints?: CustomEndpoint[]; + smtp_host?: string; + smtp_port?: number; + smtp_username?: string; + smtp_password?: string; + smtp_from_email?: string; + smtp_from_name?: string; + smtp_use_tls?: boolean; + turnstile_enabled?: boolean; + turnstile_site_key?: string; + turnstile_secret_key?: string; + linuxdo_connect_enabled?: boolean; + linuxdo_connect_client_id?: string; + linuxdo_connect_client_secret?: string; + linuxdo_connect_redirect_url?: string; + wechat_connect_enabled?: boolean; + wechat_connect_app_id?: string; + wechat_connect_app_secret?: string; + wechat_connect_mode?: string; + wechat_connect_scopes?: string; + wechat_connect_redirect_url?: string; + wechat_connect_frontend_redirect_url?: string; + oidc_connect_enabled?: boolean; + oidc_connect_provider_name?: string; + oidc_connect_client_id?: string; + oidc_connect_client_secret?: string; + oidc_connect_issuer_url?: string; + oidc_connect_discovery_url?: string; + oidc_connect_authorize_url?: string; + oidc_connect_token_url?: string; + oidc_connect_userinfo_url?: string; + oidc_connect_jwks_url?: string; + oidc_connect_scopes?: string; + oidc_connect_redirect_url?: string; + oidc_connect_frontend_redirect_url?: string; + oidc_connect_token_auth_method?: string; + oidc_connect_use_pkce?: boolean; + oidc_connect_validate_id_token?: boolean; + oidc_connect_allowed_signing_algs?: string; + oidc_connect_clock_skew_seconds?: number; + oidc_connect_require_email_verified?: boolean; + oidc_connect_userinfo_email_path?: string; + oidc_connect_userinfo_id_path?: string; + oidc_connect_userinfo_username_path?: string; + enable_model_fallback?: boolean; + fallback_model_anthropic?: string; + fallback_model_openai?: string; + fallback_model_gemini?: string; + fallback_model_antigravity?: string; + enable_identity_patch?: boolean; + identity_patch_prompt?: string; + ops_monitoring_enabled?: boolean; + ops_realtime_monitoring_enabled?: 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_enabled?: boolean - payment_min_amount?: number - payment_max_amount?: number - payment_daily_limit?: number - payment_order_timeout_minutes?: number - payment_max_pending_orders?: number - payment_enabled_types?: string[] - payment_balance_disabled?: boolean - payment_balance_recharge_multiplier?: number - payment_recharge_fee_rate?: number - payment_load_balance_strategy?: string - payment_product_name_prefix?: string - payment_product_name_suffix?: string - payment_help_image_url?: string - payment_help_text?: string - payment_cancel_rate_limit_enabled?: boolean - payment_cancel_rate_limit_max?: number - payment_cancel_rate_limit_window?: number - payment_cancel_rate_limit_unit?: string - payment_cancel_rate_limit_window_mode?: string - payment_visible_method_alipay_source?: string - payment_visible_method_wxpay_source?: string - payment_visible_method_alipay_enabled?: boolean - payment_visible_method_wxpay_enabled?: boolean - openai_advanced_scheduler_enabled?: boolean + payment_enabled?: boolean; + payment_min_amount?: number; + payment_max_amount?: number; + payment_daily_limit?: number; + payment_order_timeout_minutes?: number; + payment_max_pending_orders?: number; + payment_enabled_types?: string[]; + payment_balance_disabled?: boolean; + payment_balance_recharge_multiplier?: number; + payment_recharge_fee_rate?: number; + payment_load_balance_strategy?: string; + payment_product_name_prefix?: string; + payment_product_name_suffix?: string; + payment_help_image_url?: string; + payment_help_text?: string; + payment_cancel_rate_limit_enabled?: boolean; + payment_cancel_rate_limit_max?: number; + payment_cancel_rate_limit_window?: number; + payment_cancel_rate_limit_unit?: string; + payment_cancel_rate_limit_window_mode?: string; + payment_visible_method_alipay_source?: string; + payment_visible_method_wxpay_source?: string; + payment_visible_method_alipay_enabled?: boolean; + payment_visible_method_wxpay_enabled?: boolean; + openai_advanced_scheduler_enabled?: boolean; // Balance & quota notification - balance_low_notify_enabled?: boolean - balance_low_notify_threshold?: number - balance_low_notify_recharge_url?: string - account_quota_notify_enabled?: boolean - account_quota_notify_emails?: NotifyEmailEntry[] + balance_low_notify_enabled?: boolean; + balance_low_notify_threshold?: number; + balance_low_notify_recharge_url?: string; + account_quota_notify_enabled?: boolean; + account_quota_notify_emails?: NotifyEmailEntry[]; } /** @@ -453,8 +554,8 @@ export interface UpdateSettingsRequest { * @returns System settings */ export async function getSettings(): Promise { - const { data } = await apiClient.get('/admin/settings') - return data + const { data } = await apiClient.get("/admin/settings"); + return data; } /** @@ -462,20 +563,25 @@ export async function getSettings(): Promise { * @param settings - Partial settings to update * @returns Updated settings */ -export async function updateSettings(settings: UpdateSettingsRequest): Promise { - const { data } = await apiClient.put('/admin/settings', settings) - return data +export async function updateSettings( + settings: UpdateSettingsRequest, +): Promise { + const { data } = await apiClient.put( + "/admin/settings", + settings, + ); + return data; } /** * Test SMTP connection request */ export interface TestSmtpRequest { - smtp_host: string - smtp_port: number - smtp_username: string - smtp_password: string - smtp_use_tls: boolean + smtp_host: string; + smtp_port: number; + smtp_username: string; + smtp_password: string; + smtp_use_tls: boolean; } /** @@ -483,23 +589,28 @@ export interface TestSmtpRequest { * @param config - SMTP configuration to test * @returns Test result message */ -export async function testSmtpConnection(config: TestSmtpRequest): Promise<{ message: string }> { - const { data } = await apiClient.post<{ message: string }>('/admin/settings/test-smtp', config) - return data +export async function testSmtpConnection( + config: TestSmtpRequest, +): Promise<{ message: string }> { + const { data } = await apiClient.post<{ message: string }>( + "/admin/settings/test-smtp", + config, + ); + return data; } /** * Send test email request */ export interface SendTestEmailRequest { - email: string - smtp_host: string - smtp_port: number - smtp_username: string - smtp_password: string - smtp_from_email: string - smtp_from_name: string - smtp_use_tls: boolean + email: string; + smtp_host: string; + smtp_port: number; + smtp_username: string; + smtp_password: string; + smtp_from_email: string; + smtp_from_name: string; + smtp_use_tls: boolean; } /** @@ -507,20 +618,22 @@ export interface SendTestEmailRequest { * @param request - Email address and SMTP config * @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 }>( - '/admin/settings/send-test-email', - request - ) - return data + "/admin/settings/send-test-email", + request, + ); + return data; } /** * Admin API Key status response */ export interface AdminApiKeyStatus { - exists: boolean - masked_key: string + exists: boolean; + masked_key: string; } /** @@ -528,8 +641,10 @@ export interface AdminApiKeyStatus { * @returns Status indicating if key exists and masked version */ export async function getAdminApiKey(): Promise { - const { data } = await apiClient.get('/admin/settings/admin-api-key') - return data + const { data } = await apiClient.get( + "/admin/settings/admin-api-key", + ); + return data; } /** @@ -537,8 +652,10 @@ export async function getAdminApiKey(): Promise { * @returns The new full API key (only shown once) */ export async function regenerateAdminApiKey(): Promise<{ key: string }> { - const { data } = await apiClient.post<{ key: string }>('/admin/settings/admin-api-key/regenerate') - return data + const { data } = await apiClient.post<{ key: string }>( + "/admin/settings/admin-api-key/regenerate", + ); + return data; } /** @@ -546,8 +663,10 @@ export async function regenerateAdminApiKey(): Promise<{ key: string }> { * @returns Success message */ export async function deleteAdminApiKey(): Promise<{ message: string }> { - const { data } = await apiClient.delete<{ message: string }>('/admin/settings/admin-api-key') - return data + const { data } = await apiClient.delete<{ message: string }>( + "/admin/settings/admin-api-key", + ); + return data; } // ==================== Overload Cooldown Settings ==================== @@ -556,23 +675,25 @@ export async function deleteAdminApiKey(): Promise<{ message: string }> { * Overload cooldown settings interface (529 handling) */ export interface OverloadCooldownSettings { - enabled: boolean - cooldown_minutes: number + enabled: boolean; + cooldown_minutes: number; } export async function getOverloadCooldownSettings(): Promise { - const { data } = await apiClient.get('/admin/settings/overload-cooldown') - return data + const { data } = await apiClient.get( + "/admin/settings/overload-cooldown", + ); + return data; } export async function updateOverloadCooldownSettings( - settings: OverloadCooldownSettings + settings: OverloadCooldownSettings, ): Promise { const { data } = await apiClient.put( - '/admin/settings/overload-cooldown', - settings - ) - return data + "/admin/settings/overload-cooldown", + settings, + ); + return data; } // ==================== Stream Timeout Settings ==================== @@ -581,11 +702,11 @@ export async function updateOverloadCooldownSettings( * Stream timeout settings interface */ export interface StreamTimeoutSettings { - enabled: boolean - action: 'temp_unsched' | 'error' | 'none' - temp_unsched_minutes: number - threshold_count: number - threshold_window_minutes: number + enabled: boolean; + action: "temp_unsched" | "error" | "none"; + temp_unsched_minutes: number; + threshold_count: number; + threshold_window_minutes: number; } /** @@ -593,8 +714,10 @@ export interface StreamTimeoutSettings { * @returns Stream timeout settings */ export async function getStreamTimeoutSettings(): Promise { - const { data } = await apiClient.get('/admin/settings/stream-timeout') - return data + const { data } = await apiClient.get( + "/admin/settings/stream-timeout", + ); + return data; } /** @@ -603,13 +726,13 @@ export async function getStreamTimeoutSettings(): Promise * @returns Updated settings */ export async function updateStreamTimeoutSettings( - settings: StreamTimeoutSettings + settings: StreamTimeoutSettings, ): Promise { const { data } = await apiClient.put( - '/admin/settings/stream-timeout', - settings - ) - return data + "/admin/settings/stream-timeout", + settings, + ); + return data; } // ==================== Rectifier Settings ==================== @@ -618,11 +741,11 @@ export async function updateStreamTimeoutSettings( * Rectifier settings interface */ export interface RectifierSettings { - enabled: boolean - thinking_signature_enabled: boolean - thinking_budget_enabled: boolean - apikey_signature_enabled: boolean - apikey_signature_patterns: string[] + enabled: boolean; + thinking_signature_enabled: boolean; + thinking_budget_enabled: boolean; + apikey_signature_enabled: boolean; + apikey_signature_patterns: string[]; } /** @@ -630,8 +753,10 @@ export interface RectifierSettings { * @returns Rectifier settings */ export async function getRectifierSettings(): Promise { - const { data } = await apiClient.get('/admin/settings/rectifier') - return data + const { data } = await apiClient.get( + "/admin/settings/rectifier", + ); + return data; } /** @@ -640,13 +765,13 @@ export async function getRectifierSettings(): Promise { * @returns Updated settings */ export async function updateRectifierSettings( - settings: RectifierSettings + settings: RectifierSettings, ): Promise { const { data } = await apiClient.put( - '/admin/settings/rectifier', - settings - ) - return data + "/admin/settings/rectifier", + settings, + ); + return data; } // ==================== Beta Policy Settings ==================== @@ -655,20 +780,20 @@ export async function updateRectifierSettings( * Beta policy rule interface */ export interface BetaPolicyRule { - beta_token: string - action: 'pass' | 'filter' | 'block' - scope: 'all' | 'oauth' | 'apikey' | 'bedrock' - error_message?: string - model_whitelist?: string[] - fallback_action?: 'pass' | 'filter' | 'block' - fallback_error_message?: string + beta_token: string; + action: "pass" | "filter" | "block"; + scope: "all" | "oauth" | "apikey" | "bedrock"; + error_message?: string; + model_whitelist?: string[]; + fallback_action?: "pass" | "filter" | "block"; + fallback_error_message?: string; } /** * Beta policy settings interface */ export interface BetaPolicySettings { - rules: BetaPolicyRule[] + rules: BetaPolicyRule[]; } /** @@ -676,8 +801,10 @@ export interface BetaPolicySettings { * @returns Beta policy settings */ export async function getBetaPolicySettings(): Promise { - const { data } = await apiClient.get('/admin/settings/beta-policy') - return data + const { data } = await apiClient.get( + "/admin/settings/beta-policy", + ); + return data; } /** @@ -686,70 +813,73 @@ export async function getBetaPolicySettings(): Promise { * @returns Updated settings */ export async function updateBetaPolicySettings( - settings: BetaPolicySettings + settings: BetaPolicySettings, ): Promise { const { data } = await apiClient.put( - '/admin/settings/beta-policy', - settings - ) - return data + "/admin/settings/beta-policy", + settings, + ); + return data; } // --- Web Search Emulation Config --- export interface WebSearchProviderConfig { - type: 'brave' | 'tavily' - api_key: string - api_key_configured: boolean - quota_limit: number | null - subscribed_at: number | null - quota_used?: number - proxy_id: number | null - expires_at: number | null + type: "brave" | "tavily"; + api_key: string; + api_key_configured: boolean; + quota_limit: number | null; + subscribed_at: number | null; + quota_used?: number; + proxy_id: number | null; + expires_at: number | null; } export interface WebSearchEmulationConfig { - enabled: boolean - providers: WebSearchProviderConfig[] + enabled: boolean; + providers: WebSearchProviderConfig[]; } export interface WebSearchTestResult { - provider: string - results: { url: string; title: string; snippet: string; page_age?: string }[] - query: string + provider: string; + results: { url: string; title: string; snippet: string; page_age?: string }[]; + query: string; } export async function getWebSearchEmulationConfig(): Promise { const { data } = await apiClient.get( - '/admin/settings/web-search-emulation' - ) - return data + "/admin/settings/web-search-emulation", + ); + return data; } export async function updateWebSearchEmulationConfig( - config: WebSearchEmulationConfig + config: WebSearchEmulationConfig, ): Promise { const { data } = await apiClient.put( - '/admin/settings/web-search-emulation', - config - ) - return data + "/admin/settings/web-search-emulation", + config, + ); + return data; } export async function testWebSearchEmulation( - query: string + query: string, ): Promise { const { data } = await apiClient.post( - '/admin/settings/web-search-emulation/test', - { query } - ) - return data + "/admin/settings/web-search-emulation/test", + { query }, + ); + return data; } -export async function resetWebSearchUsage( - payload: { provider_type: string } -): Promise { - await apiClient.post('/admin/settings/web-search-emulation/reset-usage', payload) +export async function resetWebSearchUsage(payload: { + provider_type: string; +}): Promise { + await apiClient.post( + "/admin/settings/web-search-emulation/reset-usage", + payload, + ); } export const settingsAPI = { @@ -771,7 +901,7 @@ export const settingsAPI = { getWebSearchEmulationConfig, updateWebSearchEmulationConfig, testWebSearchEmulation, - resetWebSearchUsage -} + resetWebSearchUsage, +}; -export default settingsAPI +export default settingsAPI; diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index e56afe5f..9612d9f9 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -3,7 +3,9 @@
-
+
@@ -15,7 +17,10 @@ v-for="tab in settingsTabs" :key="tab.key" type="button" - :class="['settings-tab', activeTab === tab.key && 'settings-tab-active']" + :class="[ + 'settings-tab', + activeTab === tab.key && 'settings-tab-active', + ]" @click="activeTab = tab.key" > @@ -28,478 +33,4084 @@
- -
-
-

- {{ t('admin.settings.adminApiKey.title') }} -

-

- {{ t('admin.settings.adminApiKey.description') }} -

-
-
- + +
-
- -

- {{ t('admin.settings.adminApiKey.securityWarning') }} -

-
+

+ {{ t("admin.settings.adminApiKey.title") }} +

+

+ {{ t("admin.settings.adminApiKey.description") }} +

- - -
-
- {{ t('common.loading') }} -
- - -
- - {{ t('admin.settings.adminApiKey.notConfigured') }} - - -
- - -
-
-
- - - {{ adminApiKeyMasked }} - -
-
- - -
-
- - +
+
-

- {{ t('admin.settings.adminApiKey.keyWarning') }} -

-
- - {{ newAdminApiKey }} - - +
+ +

+ {{ t("admin.settings.adminApiKey.securityWarning") }} +

+
+
+ + +
+
+ {{ t("common.loading") }} +
+ + +
+ + {{ t("admin.settings.adminApiKey.notConfigured") }} + + +
+ + +
+
+
+ + + {{ adminApiKeyMasked }} + +
+
+ + +
+
+ + +
+

+ {{ t("admin.settings.adminApiKey.keyWarning") }} +

+
+ + {{ newAdminApiKey }} + + +
+

+ {{ t("admin.settings.adminApiKey.usage") }} +

-

- {{ t('admin.settings.adminApiKey.usage') }} -

-
+
- - -
-
-

- {{ t('admin.settings.overloadCooldown.title') }} -

-

- {{ t('admin.settings.overloadCooldown.description') }} -

-
-
-
-
- {{ t('common.loading') }} + +
+
+

+ {{ t("admin.settings.overloadCooldown.title") }} +

+

+ {{ t("admin.settings.overloadCooldown.description") }} +

- - -
-
- - -
-
-

- {{ t('admin.settings.streamTimeout.title') }} -

-

- {{ t('admin.settings.streamTimeout.description') }} -

-
-
- -
-
- {{ t('common.loading') }} -
- - -
-
- - -
-
-

- {{ t('admin.settings.rectifier.title') }} -

-

- {{ t('admin.settings.rectifier.description') }} -

-
-
- -
-
- {{ t('common.loading') }} -
- - +
-
- - - + +
- -
-
-
- -
-

- {{ t('admin.settings.emailTabDisabledTitle') }} -

-

- {{ t('admin.settings.emailTabDisabledHint') }} -

+ +
+
+
+ +
+

+ {{ t("admin.settings.emailTabDisabledTitle") }} +

+

+ {{ t("admin.settings.emailTabDisabledHint") }} +

+
-
- -
-
-
-

- {{ t('admin.settings.smtp.title') }} -

-

- {{ t('admin.settings.smtp.description') }} -

-
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -

- {{ - form.smtp_password_configured - ? t('admin.settings.smtp.passwordConfiguredHint') - : t('admin.settings.smtp.passwordHint') - }} -

-
-
- - -
-
- - -
-
- - + +
- -

- {{ t('admin.settings.smtp.useTlsHint') }} +

+ {{ t("admin.settings.smtp.title") }} +

+

+ {{ t("admin.settings.smtp.description") }}

- -
- -
-
- - -
-
-

- {{ t('admin.settings.testEmail.title') }} -

-

- {{ t('admin.settings.testEmail.description') }} -

-
-
-
-
- - -
-
-
- -
-
-

- {{ t('admin.settings.balanceNotify.title') }} -

-

- {{ t('admin.settings.balanceNotify.description') }} -

-
-
-
- - -
-
- -
- $ - -
-

{{ t('admin.settings.balanceNotify.thresholdHint') }}

-
-
- - -

{{ t('admin.settings.balanceNotify.rechargeUrlHint') }}

-
-
-
- - -
-
-

- {{ t('admin.settings.quotaNotify.title') }} -

-

- {{ t('admin.settings.quotaNotify.description') }} -

-
-
-
- - -
-
- -
-
-
+ + +
+
+

+ {{ t("admin.settings.testEmail.title") }} +

+

+ {{ t("admin.settings.testEmail.description") }} +

+
+
+
+
+ + +
+
-

{{ t('admin.settings.quotaNotify.emailsHint') }}

+
+
+ +
+
+

+ {{ t("admin.settings.balanceNotify.title") }} +

+

+ {{ t("admin.settings.balanceNotify.description") }} +

+
+
+
+ + +
+
+ +
+ $ + +
+

+ {{ t("admin.settings.balanceNotify.thresholdHint") }} +

+
+
+ + +

+ {{ t("admin.settings.balanceNotify.rechargeUrlHint") }} +

+
+
+
+ + +
+
+

+ {{ t("admin.settings.quotaNotify.title") }} +

+

+ {{ t("admin.settings.quotaNotify.description") }} +

+
+
+
+ + +
+
+ +
+
+ + + +
+ +
+

+ {{ t("admin.settings.quotaNotify.emailsHint") }} +

+
-
+
@@ -3070,8 +4644,17 @@
-
@@ -3104,22 +4691,32 @@ @close="showProviderDialog = false" @save="handleSaveProvider" /> - +