diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index ebc2c11dfe..14e80ac56e 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -383,6 +383,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { GrokDefaultBaseURLMode: settings.GrokDefaultBaseURLMode, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, + SubscriptionEnabled: settings.SubscriptionEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index 66810e70b4..f888d53adf 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -579,6 +579,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled { changed = append(changed, "available_channels_enabled") } + if before.SubscriptionEnabled != after.SubscriptionEnabled { + changed = append(changed, "subscription_enabled") + } if before.ModelPlazaEnabled != after.ModelPlazaEnabled { changed = append(changed, "model_plaza_enabled") } diff --git a/backend/internal/handler/admin/setting_handler_partial_payload_test.go b/backend/internal/handler/admin/setting_handler_partial_payload_test.go index b4c94809bb..646ea15aa8 100644 --- a/backend/internal/handler/admin/setting_handler_partial_payload_test.go +++ b/backend/internal/handler/admin/setting_handler_partial_payload_test.go @@ -186,3 +186,20 @@ func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *t require.Equal(t, http.StatusBadRequest, rec.Code) require.Contains(t, rec.Body.String(), "positive integer") } + +// subscription_enabled is an opt-out switch: an explicit false is written as-is, +// and a later payload that omits the field keeps the stored value. +func TestUpdateSettingsSubscriptionEnabledIsWritableAndKeptWhenOmitted(t *testing.T) { + h, repo := newStepUpSwitchTestHandler(t, map[string]string{ + service.SettingKeySubscriptionEnabled: "true", + }) + + rec := doUpdateSettings(t, h, map[string]any{"subscription_enabled": false}, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled]) + + rec = doUpdateSettings(t, h, map[string]any{"site_name": "Example Gateway"}, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled], + "a payload without subscription_enabled must not flip the stored value back to true") +} diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index 4d9447bd0e..3f20bbe1f6 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -344,6 +344,9 @@ type UpdateSettingsRequest struct { // Available Channels feature switch (user-facing) AvailableChannelsEnabled *bool `json:"available_channels_enabled"` + // Subscription feature switch (user-facing subscription surface; see SettingKeySubscriptionEnabled) + SubscriptionEnabled *bool `json:"subscription_enabled"` + // Model Plaza feature switches + description ModelPlazaEnabled *bool `json:"model_plaza_enabled"` ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"` @@ -1937,6 +1940,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.AvailableChannelsEnabled }(), + SubscriptionEnabled: func() bool { + if req.SubscriptionEnabled != nil { + return *req.SubscriptionEnabled + } + return previousSettings.SubscriptionEnabled + }(), ModelPlazaEnabled: func() bool { if req.ModelPlazaEnabled != nil { return *req.ModelPlazaEnabled @@ -2380,6 +2389,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { GrokDefaultBaseURLMode: updatedSettings.GrokDefaultBaseURLMode, AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled, + SubscriptionEnabled: updatedSettings.SubscriptionEnabled, ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled, ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index eba009a7e6..fa32c930cd 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -317,6 +317,10 @@ type SystemSettings struct { // Available Channels feature switch (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` + // Subscription feature switch: gates the whole user-facing subscription surface + // (sidebar entries, purchase-page subscription tab, header badge, /subscriptions route). + SubscriptionEnabled bool `json:"subscription_enabled"` + // Model Plaza feature (public group/model pricing showcase) ModelPlazaEnabled bool `json:"model_plaza_enabled"` ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` @@ -403,7 +407,11 @@ type PublicSettings struct { GoogleOAuthEnabled bool `json:"google_oauth_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` PaymentEnabled bool `json:"payment_enabled"` - Version string `json:"version"` + // PaymentBalanceDisabled mirrors the payment-config BALANCE_PAYMENT_DISABLED switch so the + // user shell can derive the site billing mode (recharge & subscription / recharge only / + // subscription only) before any authenticated checkout call. + PaymentBalanceDisabled bool `json:"payment_balance_disabled"` + Version string `json:"version"` // 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。 // 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。 ServerTimezone string `json:"server_timezone"` @@ -422,6 +430,8 @@ type PublicSettings struct { AvailableChannelsEnabled bool `json:"available_channels_enabled"` + SubscriptionEnabled bool `json:"subscription_enabled"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` PluginManagementEnabled bool `json:"plugin_management_enabled"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index ec597a7641..aa204923f8 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -95,6 +95,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { GoogleOAuthEnabled: settings.GoogleOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, PaymentEnabled: settings.PaymentEnabled, + PaymentBalanceDisabled: settings.PaymentBalanceDisabled, Version: h.version, ServerTimezone: timezone.Name(), ServerUTCOffset: timezone.UTCOffset(), @@ -111,6 +112,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, + SubscriptionEnabled: settings.SubscriptionEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 7ef6745960..2cbb4678c3 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -995,6 +995,7 @@ func TestAPIContracts(t *testing.T) { "channel_monitor_hide_user_ranking": false, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, + "subscription_enabled": true, "model_plaza_enabled": false, "model_plaza_require_auth": false, "model_plaza_description": "", @@ -1309,6 +1310,7 @@ func TestAPIContracts(t *testing.T) { "channel_monitor_hide_user_ranking": false, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, + "subscription_enabled": true, "model_plaza_enabled": false, "model_plaza_require_auth": false, "model_plaza_description": "", diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 3e0cb28c5b..16fa517bc4 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -513,6 +513,15 @@ const ( // sidebar entry is hidden. Defaults to false (opt-in feature). SettingKeyAvailableChannelsEnabled = "available_channels_enabled" + // SettingKeySubscriptionEnabled is a DB-backed soft switch for the user-facing + // subscription surface: sidebar entries, purchase-page subscription tab, header + // progress badge, usage billing-type filter and the /subscriptions route. When + // false users can no longer buy or browse subscriptions from the UI; the + // subscriptions API, existing subscription billing and admin subscription + // management are unaffected. Together with BALANCE_PAYMENT_DISABLED it forms the + // admin "site billing mode" selector. Defaults to true (opt-out feature). + SettingKeySubscriptionEnabled = "subscription_enabled" + // SettingKeyModelPlazaEnabled is a DB-backed soft switch for the Model Plaza page // (public group/model pricing showcase). When false: the plaza endpoint returns 404 // and the header entry is hidden. Defaults to false (opt-in feature). diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 7fc929e9c6..dc9366ba9e 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -202,6 +202,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // Available channels feature (default disabled; opt-in) SettingKeyAvailableChannelsEnabled: "false", + // Subscription feature (default enabled; opt-out) + SettingKeySubscriptionEnabled: "true", + // Model plaza feature (default disabled; opt-in, public unless require_auth) SettingKeyModelPlazaEnabled: "false", SettingKeyModelPlazaRequireAuth: "false", @@ -820,6 +823,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // Available channels feature (default: disabled; strict true) result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" + // Subscription feature (default: enabled; only an explicit false disables) + result.SubscriptionEnabled = !isFalseSettingValue(settings[SettingKeySubscriptionEnabled]) + // Model plaza feature (default: disabled; strict true) result.ModelPlazaEnabled = settings[SettingKeyModelPlazaEnabled] == "true" result.ModelPlazaRequireAuth = settings[SettingKeyModelPlazaRequireAuth] == "true" diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index d985ce6dd6..9bf3c7d915 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -215,6 +215,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyWeChatConnectFrontendRedirectURL, SettingKeyBackendModeEnabled, SettingPaymentEnabled, + SettingBalancePayDisabled, SettingKeyOIDCConnectEnabled, SettingKeyOIDCConnectProviderName, SettingKeyGitHubOAuthEnabled, @@ -234,6 +235,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyChannelMonitorShowQuota, SettingKeyChannelMonitorHideUserRanking, SettingKeyAvailableChannelsEnabled, + SettingKeySubscriptionEnabled, SettingKeyModelPlazaEnabled, SettingKeyModelPlazaRequireAuth, SettingKeyPluginManagementEnabled, @@ -345,6 +347,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings WeChatOAuthMobileEnabled: weChatMobileEnabled, BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true", PaymentEnabled: settings[SettingPaymentEnabled] == "true", + PaymentBalanceDisabled: settings[SettingBalancePayDisabled] == "true", OIDCOAuthEnabled: oidcEnabled, OIDCOAuthProviderName: oidcProviderName, GitHubOAuthEnabled: gitHubEnabled, @@ -363,6 +366,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", + SubscriptionEnabled: !isFalseSettingValue(settings[SettingKeySubscriptionEnabled]), + ModelPlazaEnabled: settings[SettingKeyModelPlazaEnabled] == "true", ModelPlazaRequireAuth: settings[SettingKeyModelPlazaRequireAuth] == "true", PluginManagementEnabled: settings[SettingKeyPluginManagementEnabled] == "true", @@ -605,6 +610,7 @@ type PublicSettingsInjectionPayload struct { GoogleOAuthEnabled bool `json:"google_oauth_enabled"` BackendModeEnabled bool `json:"backend_mode_enabled"` PaymentEnabled bool `json:"payment_enabled"` + PaymentBalanceDisabled bool `json:"payment_balance_disabled"` Version string `json:"version"` // 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用 ServerTimezone string `json:"server_timezone"` @@ -630,6 +636,7 @@ type PublicSettingsInjectionPayload struct { ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"` ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` + SubscriptionEnabled bool `json:"subscription_enabled"` ModelPlazaEnabled bool `json:"model_plaza_enabled"` ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` PluginManagementEnabled bool `json:"plugin_management_enabled"` @@ -697,6 +704,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any GoogleOAuthEnabled: settings.GoogleOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, PaymentEnabled: settings.PaymentEnabled, + PaymentBalanceDisabled: settings.PaymentBalanceDisabled, Version: s.version, ServerTimezone: timezone.Name(), ServerUTCOffset: timezone.UTCOffset(), @@ -712,6 +720,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, + SubscriptionEnabled: settings.SubscriptionEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, PluginManagementEnabled: settings.PluginManagementEnabled, diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 316df8ffda..d2485639c3 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -240,3 +240,72 @@ func TestSettingService_GetPublicSettings_FallsBackToConfigForWeChatOAuthCapabil require.False(t, settings.WeChatOAuthMPEnabled) require.False(t, settings.WeChatOAuthMobileEnabled) } + +// subscription_enabled gates the user-facing "My Subscriptions" sidebar entry and +// is opt-out: only an explicit "false" hides it. +func TestSettingService_GetPublicSettings_SubscriptionEnabledOnlyExplicitFalseDisables(t *testing.T) { + cases := []struct { + name string + values map[string]string + want bool + }{ + {name: "missing key defaults to enabled", values: map[string]string{}, want: true}, + {name: "empty value stays enabled", values: map[string]string{SettingKeySubscriptionEnabled: ""}, want: true}, + {name: "explicit true", values: map[string]string{SettingKeySubscriptionEnabled: "true"}, want: true}, + {name: "explicit false disables", values: map[string]string{SettingKeySubscriptionEnabled: "false"}, want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := NewSettingService(&settingPublicRepoStub{values: tc.values}, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, tc.want, settings.SubscriptionEnabled) + }) + } +} + +// The SSR injection payload must mirror the switch so the opt-out flag does not +// flicker off on first render before the async public-settings fetch resolves. +func TestSettingService_GetPublicSettingsForInjection_MirrorsSubscriptionEnabled(t *testing.T) { + for _, value := range []string{"false", "true"} { + repo := &settingPublicRepoStub{values: map[string]string{SettingKeySubscriptionEnabled: value}} + svc := NewSettingService(repo, &config.Config{}) + + raw, err := svc.GetPublicSettingsForInjection(context.Background()) + require.NoError(t, err) + payload, ok := raw.(*PublicSettingsInjectionPayload) + require.True(t, ok) + require.Equal(t, value == "true", payload.SubscriptionEnabled, "value=%q", value) + } +} + +// payment_balance_disabled is exposed publicly so the user shell can derive the site +// billing mode (recharge & subscription / recharge only / subscription only) before any +// authenticated checkout call. Strict true, mirroring the payment-config parser. +func TestSettingService_GetPublicSettings_PaymentBalanceDisabledStrictTrue(t *testing.T) { + cases := []struct { + name string + value map[string]string + want bool + }{ + {name: "missing key stays enabled", value: map[string]string{}, want: false}, + {name: "explicit false", value: map[string]string{SettingBalancePayDisabled: "false"}, want: false}, + {name: "explicit true disables balance recharge", value: map[string]string{SettingBalancePayDisabled: "true"}, want: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := NewSettingService(&settingPublicRepoStub{values: tc.value}, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.Equal(t, tc.want, settings.PaymentBalanceDisabled) + + raw, err := svc.GetPublicSettingsForInjection(context.Background()) + require.NoError(t, err) + payload, ok := raw.(*PublicSettingsInjectionPayload) + require.True(t, ok) + require.Equal(t, tc.want, payload.PaymentBalanceDisabled) + }) + } +} diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 1a4793aa55..77bac7b211 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -432,6 +432,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting // Available channels feature switch updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled) + // Subscription feature switch + updates[SettingKeySubscriptionEnabled] = strconv.FormatBool(settings.SubscriptionEnabled) + // Model plaza feature switches + description updates[SettingKeyModelPlazaEnabled] = strconv.FormatBool(settings.ModelPlazaEnabled) updates[SettingKeyModelPlazaRequireAuth] = strconv.FormatBool(settings.ModelPlazaRequireAuth) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 8216535309..93e7d76bcb 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -212,6 +212,12 @@ type SystemSettings struct { // Available Channels feature (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` + // Subscription feature switch: gates the whole user-facing subscription surface + // (sidebar entries, purchase-page subscription tab, header progress badge, + // usage billing-type filter, /subscriptions route). Pairs with PaymentBalanceDisabled + // to form the admin-facing "site billing mode" selector. + SubscriptionEnabled bool `json:"subscription_enabled"` + // Model Plaza feature (public group/model pricing showcase) ModelPlazaEnabled bool `json:"model_plaza_enabled"` ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` @@ -366,6 +372,7 @@ type PublicSettings struct { WeChatOAuthMobileEnabled bool BackendModeEnabled bool PaymentEnabled bool + PaymentBalanceDisabled bool OIDCOAuthEnabled bool OIDCOAuthProviderName string GitHubOAuthEnabled bool @@ -393,6 +400,9 @@ type PublicSettings struct { // Available Channels feature (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` + // Subscription feature switch (see SystemSettings.SubscriptionEnabled) + SubscriptionEnabled bool `json:"subscription_enabled"` + // Model Plaza feature (public group/model pricing showcase) ModelPlazaEnabled bool `json:"model_plaza_enabled"` ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1a4f7437d5..714e66975b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,6 +1,6 @@ diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 94e4605957..7a645cfe7f 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -115,7 +115,7 @@ -