feat(settings): 新增「站点类型」三态开关,关闭订阅后用户端收起所有「订阅」入口与文案

后台功能开关页新增「站点类型」单选:充值 & 订阅(默认,即现状)/ 仅充值 / 仅订阅。
它由两个后端开关派生并在保存时同时写回:
- subscription_enabled(新增;默认 true,缺省/空值视为开启,仅显式 false 关闭),按既有
  开关模式贯通常量、公共设置、SSR 注入载荷、默认值、更新写入、DTO、审计 diff 与前端注册表;
- payment_balance_disabled(支付配置里既有的 BALANCE_PAYMENT_DISABLED,此前只能走接口/数据库
  改),补进公共设置与注入载荷,让用户端在任何鉴权请求之前就能推导站点类型。
两者都关闭是接口层可表达但 UI 不提供的组合,前端归为「仅充值」,后台保存时会把余额充值重新打开。

「仅充值」时的表现:
- 用户侧边栏隐藏「我的订阅」,「充值/订阅」入口收窄为「充值」;管理端侧边栏隐藏「订阅管理」
  入口(页面仍可通过地址访问);
- 购买页不再渲染「订阅」tab,只剩单个 tab 时顶部切换器随之隐藏;tab 列表变化时当前 tab
  自动收敛到可用项(关闭订阅回到充值、仅订阅站点重新打开订阅进入订阅),列表为空时展示
  「充值与订阅均暂未开放」而不是一个必定被后端拒绝的充值表单;
- /purchase 的页头标题/描述与 document.title 改走 resolveRouteMetaKeys 按站点类型切换;
- /subscriptions 路由新增 requiresSubscription 守卫,直达时重定向到仪表盘;
- 顶栏订阅进度徽章不再挂载,App.vue、PaymentView 与徽章组件都不再预加载/轮询订阅接口;
- 用量页隐藏「计费类型(余额/订阅)」筛选,Key 用量公开页「订阅类型」行改为「计费方式」。
「仅订阅」时侧边栏入口与页头显示为「订阅」,购买页只剩订阅 tab(余额下单由后端既有逻辑拒绝)。

购买订阅接口、已有订阅的计费、兑换码发放订阅与管理端订阅管理页面本身均不受影响。
featureFlags 抽出纯函数 resolveFeatureFlag,新增 utils/siteBillingMode 承载三态派生与反解,
PaymentView 测试改用响应式 store mock 覆盖开关双向切换。
This commit is contained in:
feeeei
2026-09-11 00:50:17 +08:00
parent 98d86915be
commit 9d475f9edb
46 changed files with 938 additions and 47 deletions
@@ -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,
@@ -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")
}
@@ -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")
}
@@ -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,
+11 -1
View File
@@ -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"`
@@ -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,
@@ -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": "",
@@ -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).
@@ -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"
@@ -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,
@@ -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)
})
}
}
@@ -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)
+10
View File
@@ -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"`