From a5b0553862ee2e5bba430c6a6a33428da335bad9 Mon Sep 17 00:00:00 2001 From: erio Date: Tue, 21 Apr 2026 21:08:10 +0800 Subject: [PATCH] fix(settings): inject channel_monitor & available_channels into SSR payload Root cause: GetPublicSettingsForInjection used an inline struct that silently drifted from dto.PublicSettings and omitted channel_monitor_enabled / available_channels_enabled. On refresh window.__APP_CONFIG__ lacked these keys, so cachedPublicSettings.available_channels_enabled resolved to undefined and the opt-in sidebar entry (=== true) disappeared. Backend: extract PublicSettingsInjectionPayload as a named type with all feature-flag fields wired, and add a reflect-based drift test in the dto package so forgetting a future flag fails CI instead of the browser. Frontend: introduce utils/featureFlags.ts as the single registry for public-settings-driven toggles, with explicit opt-in / opt-out modes that encode the pre-load fallback. AppSidebar switches to makeSidebarFlag() so adding a new switch only touches the registry. --- .../public_settings_injection_schema_test.go | 68 +++++++++ backend/internal/service/setting_service.go | 105 ++++++++----- frontend/src/components/layout/AppSidebar.vue | 14 +- .../layout/__tests__/AppSidebar.spec.ts | 2 +- frontend/src/utils/featureFlags.ts | 139 ++++++++++++++++++ 5 files changed, 280 insertions(+), 48 deletions(-) create mode 100644 backend/internal/handler/dto/public_settings_injection_schema_test.go create mode 100644 frontend/src/utils/featureFlags.ts diff --git a/backend/internal/handler/dto/public_settings_injection_schema_test.go b/backend/internal/handler/dto/public_settings_injection_schema_test.go new file mode 100644 index 0000000000..24853c7da2 --- /dev/null +++ b/backend/internal/handler/dto/public_settings_injection_schema_test.go @@ -0,0 +1,68 @@ +package dto + +import ( + "reflect" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +// TestPublicSettingsInjectionPayload_SchemaDoesNotDrift guarantees the SSR +// injection struct exposes every JSON field consumed by the frontend. +// +// Why this test exists: before we extracted a named PublicSettingsInjectionPayload +// type, the inline struct was manually kept in sync with dto.PublicSettings and +// drifted — ChannelMonitorEnabled / AvailableChannelsEnabled were missing, which +// made the frontend read `undefined` on refresh and hide the "可用渠道" menu +// until the async /api/v1/settings/public round-trip finished. +// +// This test compares the two JSON-tag sets and fails if injection is missing +// any field that dto.PublicSettings exposes. Adding a new feature flag with +// only a DTO entry will fail this test until the injection struct is updated. +// +// Intentional exclusions (fields present on dto.PublicSettings that SSR does +// not need to inject) are listed in `dtoOnlyFields` below with a reason. +func TestPublicSettingsInjectionPayload_SchemaDoesNotDrift(t *testing.T) { + injection := jsonTags(reflect.TypeOf(service.PublicSettingsInjectionPayload{})) + dtoKeys := jsonTags(reflect.TypeOf(PublicSettings{})) + + // Fields that legitimately live only on the DTO. Keep tiny; document each. + dtoOnlyFields := map[string]string{ + // sora_client_enabled is an upstream-only field the fork does not surface. + "sora_client_enabled": "upstream-only field, not used on this fork", + } + + var missing []string + for key := range dtoKeys { + if _, ok := injection[key]; ok { + continue + } + if _, allowed := dtoOnlyFields[key]; allowed { + continue + } + missing = append(missing, key) + } + if len(missing) > 0 { + t.Fatalf("service.PublicSettingsInjectionPayload is missing JSON fields present on dto.PublicSettings: %s\n"+ + "add the field to PublicSettingsInjectionPayload (and GetPublicSettingsForInjection), or "+ + "document the exclusion in dtoOnlyFields with a reason.", strings.Join(missing, ", ")) + } +} + +func jsonTags(t reflect.Type) map[string]struct{} { + out := make(map[string]struct{}) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + tag := f.Tag.Get("json") + if tag == "" || tag == "-" { + continue + } + name := strings.SplitN(tag, ",", 2)[0] + if name == "" { + continue + } + out[name] = struct{}{} + } + return out +} diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 4231a2678f..2d8a9893c4 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -358,50 +358,71 @@ func (s *SettingService) SetVersion(version string) { s.version = version } -// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection -// This implements the web.PublicSettingsProvider interface +// PublicSettingsInjectionPayload is the JSON shape embedded into HTML as +// `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site +// config before the first XHR finishes. +// +// INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings. +// If you forget a feature-flag field here, the frontend's +// `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the +// async `/api/v1/settings/public` call returns — which causes opt-in menus +// (strict `=== true`) to flicker off/on. See +// frontend/src/utils/featureFlags.ts for the matching registry. +// +// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch +// drift automatically (see setting_service_injection_test.go). +type PublicSettingsInjectionPayload struct { + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems json.RawMessage `json:"custom_menu_items"` + CustomEndpoints json.RawMessage `json:"custom_endpoints"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` + Version string `json:"version"` + BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` + AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` + BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` + + // Feature flags — MUST match the opt-in/opt-out registry in + // frontend/src/utils/featureFlags.ts. Missing a field here is the bug + // that hid the "可用渠道" menu on page refresh. + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + AvailableChannelsEnabled bool `json:"available_channels_enabled"` +} + +// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. +// This implements the web.PublicSettingsProvider interface. func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) { settings, err := s.GetPublicSettings(ctx) if err != nil { return nil, err } - // Return a struct that matches the frontend's expected format - return &struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key,omitempty"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo,omitempty"` - SiteSubtitle string `json:"site_subtitle,omitempty"` - APIBaseURL string `json:"api_base_url,omitempty"` - ContactInfo string `json:"contact_info,omitempty"` - DocURL string `json:"doc_url,omitempty"` - HomeContent string `json:"home_content,omitempty"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems json.RawMessage `json:"custom_menu_items"` - CustomEndpoints json.RawMessage `json:"custom_endpoints"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - BackendModeEnabled bool `json:"backend_mode_enabled"` - PaymentEnabled bool `json:"payment_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` - Version string `json:"version,omitempty"` - BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` - AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` - BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` - BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` - }{ + return &PublicSettingsInjectionPayload{ RegistrationEnabled: settings.RegistrationEnabled, EmailVerifyEnabled: settings.EmailVerifyEnabled, RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, @@ -426,15 +447,19 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, - BackendModeEnabled: settings.BackendModeEnabled, - PaymentEnabled: settings.PaymentEnabled, OIDCOAuthEnabled: settings.OIDCOAuthEnabled, OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + BackendModeEnabled: settings.BackendModeEnabled, + PaymentEnabled: settings.PaymentEnabled, Version: s.version, BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, + + ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, + AvailableChannelsEnabled: settings.AvailableChannelsEnabled, }, nil } diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 96c19833e3..61f5a3d16e 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -186,6 +186,7 @@ import { useI18n } from 'vue-i18n' import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores' import VersionBadge from '@/components/common/VersionBadge.vue' import { sanitizeSvg } from '@/utils/sanitize' +import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags' interface NavItem { path: string @@ -627,13 +628,12 @@ const ChevronDownIcon = { ) } -// 各个开关集中声明:所有菜单项引用这里的 getter,未来加新开关只需在此加一个常量。 -// getter 返回 false = 隐藏;undefined/true = 显示(宽容策略,避免 public settings 未加载闪烁)。 -const flagChannelMonitor = () => appStore.cachedPublicSettings?.channel_monitor_enabled -const flagPayment = () => appStore.cachedPublicSettings?.payment_enabled -// Opt-in feature:默认关闭,未加载 settings 时也视为关闭(严格 === true 判定) -const flagAvailableChannels = () => - appStore.cachedPublicSettings?.available_channels_enabled === true +// Public-settings flags go through the registry in utils/featureFlags.ts, +// which handles the opt-in vs opt-out fallback when settings haven't loaded +// yet. Admin-only flags (not in public settings) stay inline below. +const flagChannelMonitor = makeSidebarFlag(FeatureFlags.channelMonitor) +const flagPayment = makeSidebarFlag(FeatureFlags.payment) +const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels) const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled const flagAdminPayment = () => adminSettingsStore.paymentEnabled diff --git a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts index 118c76159c..592ce8a3b8 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts @@ -21,7 +21,7 @@ describe('AppSidebar custom SVG styles', () => { describe('AppSidebar header styles', () => { it('does not clip the version badge dropdown', () => { - const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n \}/) + const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n {2}\}/) const sidebarBrandBlockMatch = componentSource.match(/\.sidebar-brand\s*\{[\s\S]*?\n\}/) expect(sidebarHeaderBlockMatch).not.toBeNull() diff --git a/frontend/src/utils/featureFlags.ts b/frontend/src/utils/featureFlags.ts new file mode 100644 index 0000000000..51b043cc66 --- /dev/null +++ b/frontend/src/utils/featureFlags.ts @@ -0,0 +1,139 @@ +/** + * Feature flag registry — single source of truth for public-settings-driven + * feature switches used by the sidebar, routes, and views. + * + * ## Why this module exists + * + * `public settings` reach the frontend through two channels: + * + * 1. **SSR injection** — the backend embeds `window.__APP_CONFIG__` into the + * HTML. `main.ts` calls `appStore.initFromInjectedConfig()` synchronously + * before Vue mounts, so `cachedPublicSettings` is populated on first + * render. + * 2. **Async API** — `App.vue` awaits `appStore.fetchPublicSettings()` on + * mount as a fallback (used when injection is missing or stale). + * + * If the SSR injection struct forgets to include a feature flag field — the + * exact bug that hid the "可用渠道" menu after every refresh — the frontend + * reads `undefined` until the async call resolves. An opt-in flag written as + * `settings?.xxx_enabled === true` then evaluates to `false` and the menu + * disappears. An opt-out flag written as `settings?.xxx_enabled !== false` + * evaluates to `true` (menu stays) but will flicker off if the backend sends + * `false`. + * + * This module hides that `undefined` handling behind two explicit modes. + * + * ## Modes + * + * - **`opt-out`** (default enabled) — menu visible when settings unloaded, + * hidden only when the backend explicitly sends `false`. Use for features + * that ship enabled by default (Channel Monitor, Payment). + * - **`opt-in`** (default disabled) — menu hidden when settings unloaded, + * visible only when the backend explicitly sends `true`. Use for features + * that ship disabled (Available Channels). + * + * For `opt-in` flags to render immediately on refresh, the backend **must** + * inject the field through `PublicSettingsInjectionPayload`. A drift test in + * `backend/internal/handler/dto/public_settings_injection_schema_test.go` + * catches omissions. + * + * ## Adding a new flag + * + * 1. Backend `service/domain_constants.go` → `SettingKeyEnabled` + * 2. Backend `service/settings_view.go` → `PublicSettings` + `SystemSettings` + * 3. Backend `service/setting_service.go` → `GetPublicSettings` / `UpdateSettings` / + * `GetAllSettings` / `InitDefaultSettings` / + * **`PublicSettingsInjectionPayload`** + * (the drift test enforces this) + * 4. Backend `handler/dto/settings.go` → `PublicSettings` + `SystemSettings` + * 5. Backend `handler/setting_handler.go` → handler response + * 6. Backend `handler/admin/setting_handler.go` → update request + audit diff + * 7. Frontend `types/index.ts` → `PublicSettings` typings + * 8. Frontend `api/admin/settings.ts` → admin DTO typings + * 9. **Frontend `utils/featureFlags.ts` (this file)** → register via `defineFlag` + * 10. Frontend `views/admin/SettingsView.vue` → Toggle UI + form defaults + save payload + * 11. Frontend `components/layout/AppSidebar.vue` → attach via `makeSidebarFlag` + * + * ## Usage + * + * ```ts + * import { FeatureFlags, makeSidebarFlag } from '@/utils/featureFlags' + * + * const flagAvailableChannels = makeSidebarFlag(FeatureFlags.availableChannels) + * // ... + * { path: '/available-channels', label: ..., featureFlag: flagAvailableChannels } + * ``` + * + * `isFeatureFlagEnabled(flag)` returns the resolved boolean (`true` = show). + * `makeSidebarFlag(flag)` returns a `() => boolean | undefined` compatible with + * `AppSidebar.NavItem.featureFlag`, where `false` hides the menu entry. + */ + +import { useAppStore } from '@/stores/app' +import type { PublicSettings } from '@/types' + +export type FeatureFlagMode = 'opt-in' | 'opt-out' + +export interface FeatureFlagDefinition { + /** Public-settings key used for lookup. */ + readonly key: keyof PublicSettings + /** Resolution mode when the key is missing/undefined. */ + readonly mode: FeatureFlagMode + /** Short human label for logs and debug tooling. */ + readonly label: string +} + +function defineFlag( + def: { key: K; mode: FeatureFlagMode; label: string }, +): FeatureFlagDefinition { + return def +} + +/** + * Registered feature flags. Add a new entry here when introducing a new + * public-settings-driven switch; see the "Adding a new flag" checklist above. + */ +export const FeatureFlags = { + channelMonitor: defineFlag({ + key: 'channel_monitor_enabled', + mode: 'opt-out', + label: 'Channel Monitor', + }), + availableChannels: defineFlag({ + key: 'available_channels_enabled', + mode: 'opt-in', + label: 'Available Channels', + }), + payment: defineFlag({ + key: 'payment_enabled', + mode: 'opt-out', + label: 'Payment', + }), +} as const + +export type RegisteredFeatureFlag = keyof typeof FeatureFlags + +/** + * Read the current value of a flag, honoring the mode's fallback. + * `true` → the feature is enabled (menu/route should render). + * `false` → the feature is disabled (menu/route should hide). + */ +export function isFeatureFlagEnabled(flag: FeatureFlagDefinition): boolean { + const appStore = useAppStore() + const raw = appStore.cachedPublicSettings?.[flag.key] as + | boolean + | undefined + if (typeof raw === 'boolean') return raw + // Settings not yet loaded → fall back to the flag's declared mode: + // opt-out → visible by default, opt-in → hidden by default. + return flag.mode === 'opt-out' +} + +/** + * Sidebar NavItem.featureFlag accepts a getter that returns + * `false` to hide. Keeping the same contract lets callers swap in + * registry-backed flags without changing AppSidebar's filter logic. + */ +export function makeSidebarFlag(flag: FeatureFlagDefinition): () => boolean { + return () => isFeatureFlagEnabled(flag) +}